From b2af87e267a35ba1ec73185d419e4ffa42966790 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 12:15:18 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20EmailServiceConfigSchema=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20CLI=20=E5=AE=9E=E8=AF=BB=E7=9A=84=20queueD?= =?UTF-8?q?elivery=20/=20appName=20/=20defaultTemplateContext=20(#5307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.email 在全仓只有一个读者:packages/cli/src/commands/serve.ts 的 resolveEmailCapabilityArg。它读八个键,schema 只声明五个,差集三个已经被 运行时消费多时 —— queueDelivery(#5160 的耐久队列开关)、appName(模板 产品名 + 兜底发件人来源)、defaultTemplateContext(自由渲染上下文)。 与 #5104 完全同型的 declared != implemented,spec 在落后的一侧:用 EmailServiceConfig 标注 objectstack.config.ts 的作者写 queueDelivery: true 会拿到类型错误,而同一份配置起得来、也确实走队列。 - 三个键均为 optional 且不带 .default():默认值由 resolveEmailCapabilityArg 对着 env 与顶层 config 解析,schema 再造一个只会多出一个谁也不赢的答案 - defaultTemplateContext 保持自由 record,读侧原样透传,不发明约束 - TSDoc 写清语义、默认值与优先级,含 defaultTemplateContext.appName 压过 OS_APP_NAME 这一处实测到的例外(另立 #5448) - 新增跨包契约测试把 issue 的手工 grep 机械化:读侧新增未声明键即变红, persist 作为唯一 DECLARED_BUT_UNREAD 豁免登记并指回 #5447 运行时零改动。 --- .changeset/email-config-runtime-read-keys.md | 28 +++ ...serve-email-config-parity.contract.test.ts | 172 ++++++++++++++++++ packages/spec/src/system/email-config.test.ts | 82 +++++++++ packages/spec/src/system/email-config.zod.ts | 85 +++++++++ 4 files changed, 367 insertions(+) create mode 100644 .changeset/email-config-runtime-read-keys.md create mode 100644 packages/cli/src/commands/serve-email-config-parity.contract.test.ts diff --git a/.changeset/email-config-runtime-read-keys.md b/.changeset/email-config-runtime-read-keys.md new file mode 100644 index 0000000000..36fbd6a371 --- /dev/null +++ b/.changeset/email-config-runtime-read-keys.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +spec: `EmailServiceConfigSchema` 补齐 CLI 实读的 `queueDelivery` / `appName` / `defaultTemplateContext` + +`config.email` 在全仓只有一个读者 —— `packages/cli/src/commands/serve.ts` 的 +`resolveEmailCapabilityArg`。它读八个键,`EmailServiceConfigSchema` 只声明其中五个, +差集三个已经被运行时消费多时: + +- `queueDelivery` —— #5160 落地的耐久队列投递开关(env 侧 `OS_EMAIL_QUEUE_ENABLED`) +- `appName` —— 模板 `{{appName}}` 的产品名,兼作无 `defaultFrom` 时的兜底发件人来源 +- `defaultTemplateContext` —— 合并进每次 `sendTemplate()` 的自由渲染上下文 + +于是用 `EmailServiceConfig` 标注 `objectstack.config.ts` 的作者写 `queueDelivery: true` +会拿到类型错误,而同一份配置 `os serve` 起得来、也确实走队列投递;生成的参考文档 +`content/docs/references/system/email-config.mdx` 的属性表同样看不到这三个键,AI 作者 +读到的是「不支持」。与 #5104(provider 缺 `smtp`)完全同型,只是键不同。 + +本次是把契约追平既成事实,**运行时零改动**:三个键都是 optional,不带 `.default()` +(默认值由 `resolveEmailCapabilityArg` 对着 env 与顶层 config 解析,schema 再造一个只会 +多出一个谁也不赢的答案),`defaultTemplateContext` 保持自由 record —— 读侧原样透传, +声明一套读侧没有的约束等于发明契约。 + +同时新增跨包契约测试 `serve-email-config-parity.contract.test.ts`,把 issue 里那条手工 +grep 机械化:读侧多出一个未声明的键即变红,不必再等下一次人工比对。 + +Fixes #5307 diff --git a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts new file mode 100644 index 0000000000..b0e95381c2 --- /dev/null +++ b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `EmailServiceConfigSchema` (spec) ↔ the keys `resolveEmailCapabilityArg` +// actually reads off `config.email` — #5307. +// +// `config.email` has exactly ONE reader in the whole repo (this file's +// neighbour, `serve.ts`), and `EmailServiceConfigSchema` is the operator-facing +// contract for it. Nothing held the two together, so the schema fell behind the +// reader twice in one family: +// +// - #5104 — `provider: 'smtp'` shipped in #5087 and the enum still stopped at +// postmark, so annotating `objectstack.config.ts` with `EmailServiceConfig` +// made a working config a type error. +// - #5307 — `queueDelivery` (the #5160 durable-queue switch), `appName` and +// `defaultTemplateContext` were read here and declared nowhere. Same shape, +// three more keys, and the generated reference docs told authors the keys +// did not exist. +// +// Both were found by hand, with a grep. This file is that grep, mechanised, so +// the third one fails a build instead of waiting for someone to run it: +// a key added to the reader without a declaration is red here the day it lands. +// +// It is a CROSS-PACKAGE assertion for the reason the provider-parity test in +// `@objectstack/plugin-email` gives: two mirrored literals can always be +// "fixed" by editing the other literal. `@objectstack/spec` is a real +// dependency of this package, and the comparison is test-only — no runtime edge +// is added. +// +// This package's `tsconfig.json` includes `src` (tests and all), so the +// compile-time witness below is real: `pnpm --filter @objectstack/cli +// typecheck` fails on a config the schema cannot express, before any test runs. +// The spec-side companion (`packages/spec/src/system/email-config.test.ts`) has +// to be runtime-only — that package excludes `**/*.test.ts` from its tsconfig +// (#5286). + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { EmailServiceConfigSchema } from '@objectstack/spec/system'; +import type { EmailServiceConfig } from '@objectstack/spec/system'; +import { resolveEmailCapabilityArg } from './serve.js'; + +const SERVE_SOURCE = readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), + 'utf8', +); + +/** + * Every `config.email` key the resolver reads, straight from its source — the + * issue's own repro command: + * + * grep -oE "cfgEmail\.[a-zA-Z]+" packages/cli/src/commands/serve.ts | sort -u + * + * `cfgEmail` is the resolver's parameter name for `config.email`, and every + * read of it in that function is a dot access (no destructuring, no computed + * keys), which is what makes a source scan an exact measure rather than an + * approximation. Should that ever stop being true, this comment is the place + * the next reader learns the scan has to change with it. + */ +function keysReadFromConfigEmail(): string[] { + const reads = SERVE_SOURCE.match(/cfgEmail\.[A-Za-z_$][\w$]*/g) ?? []; + return [...new Set(reads.map((r) => r.slice('cfgEmail.'.length)))].sort(); +} + +/** Every key the authoring contract declares. */ +function keysDeclaredBySchema(): string[] { + return Object.keys(EmailServiceConfigSchema.shape).sort(); +} + +/** + * `persist` is declared and NOT read — the mirror image of #5307, filed + * separately (#5447) and deliberately untouched here. + * + * The plugin option it names is live (`EmailServicePlugin` builds no + * `EmailPersistence` when `persist === false`), but nothing carries + * `config.email.persist` to the plugin: `resolveEmailCapabilityArg` never + * reads the key, and it is the only reader `config.email` has. So the schema + * advertises an authoring surface with no carrier — ADR-0049 enforce-or-remove + * territory, whose two answers (wire it in the resolver, or retire the key) + * are a contract decision, not a drive-by. + * + * It is listed rather than silently excused: when #5447 is settled this array + * empties and the assertion below tightens to plain set equality. + */ +const DECLARED_BUT_UNREAD = ['persist'] as const; + +describe('EmailServiceConfigSchema ↔ resolveEmailCapabilityArg', () => { + it('declares every config.email key the resolver reads (#5307)', () => { + const declared = new Set(keysDeclaredBySchema()); + const undeclared = keysReadFromConfigEmail().filter((k) => !declared.has(k)); + // Red before #5307 with ['appName', 'defaultTemplateContext', 'queueDelivery']. + expect(undeclared).toEqual([]); + }); + + it('reads every key it declares, but for the filed exemption (#5447)', () => { + const read = new Set(keysReadFromConfigEmail()); + const unread = keysDeclaredBySchema().filter((k) => !read.has(k)); + expect(unread).toEqual([...DECLARED_BUT_UNREAD]); + }); + + it('pins the measured key set so a rename is a conscious edit', () => { + expect(keysDeclaredBySchema()).toEqual([ + 'apiKey', + 'appName', + 'defaultFrom', + 'defaultTemplateContext', + 'options', + 'persist', + 'provider', + 'queueDelivery', + 'retries', + ]); + }); +}); + +/** + * Compile-time half — the author-facing symptom #5307 is written about. Every + * value here is one the runtime has honoured since #5160; before the + * declaration each of the last three was a type error on a config that booted + * and worked. + */ +const AUTHORED_CONFIG: EmailServiceConfig = { + provider: 'smtp', + options: { host: 'smtp.acme.test', port: 465, secure: true }, + defaultFrom: { name: 'Acme CRM', address: 'no-reply@acme.test' }, + retries: 3, + queueDelivery: true, + appName: 'Acme CRM', + defaultTemplateContext: { supportEmail: 'help@acme.test' }, +}; + +describe('a config the schema accepts reaches the plugin intact', () => { + it('carries the three keys from parse() through to the plugin options', () => { + // Name equality is not enough: the schema could declare `queueDelivery` + // with a shape the resolver ignores. So parse an authored config with the + // real schema and feed the RESULT to the real reader. + const parsed = EmailServiceConfigSchema.parse(AUTHORED_CONFIG); + const { options } = resolveEmailCapabilityArg(parsed as Record, {}); + + expect(options).toMatchObject({ + provider: 'smtp', + queueDelivery: true, + defaultTemplateContext: { appName: 'Acme CRM', supportEmail: 'help@acme.test' }, + }); + }); + + it('lets appName name the deployment for templates and the placeholder sender', () => { + const parsed = EmailServiceConfigSchema.parse({ appName: 'Acme CRM' }); + const { options } = resolveEmailCapabilityArg(parsed as Record, {}); + + expect(options.defaultTemplateContext).toMatchObject({ appName: 'Acme CRM' }); + expect(options.defaultFrom).toEqual({ name: 'Acme CRM', address: 'no-reply@acme-crm.local' }); + }); + + it('spreads defaultTemplateContext OVER the resolved appName, as documented', () => { + // Not the precedence the header TSDoc's "env overrides per setting" line + // would predict — `OS_APP_NAME` loses to an `appName` written inside + // `defaultTemplateContext`. Measured, documented in the schema, and filed + // as #5448; pinned here so a change to it is deliberate rather than + // discovered by an operator whose branded mail says the wrong name. + const parsed = EmailServiceConfigSchema.parse({ + appName: 'From The Key', + defaultTemplateContext: { appName: 'From The Context' }, + }); + const { options } = resolveEmailCapabilityArg(parsed as Record, { + OS_APP_NAME: 'From The Env', + }); + + expect(options.defaultTemplateContext).toMatchObject({ appName: 'From The Context' }); + }); +}); diff --git a/packages/spec/src/system/email-config.test.ts b/packages/spec/src/system/email-config.test.ts index b8fe593769..6021a83ee4 100644 --- a/packages/spec/src/system/email-config.test.ts +++ b/packages/spec/src/system/email-config.test.ts @@ -104,3 +104,85 @@ describe('EmailServiceConfigSchema', () => { if (parsed.success) expect(parsed.data.provider).toBe('log'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #5307 — the same defect as #5104, on three other keys. +// +// `resolveEmailCapabilityArg` reads `queueDelivery` / `appName` / +// `defaultTemplateContext` off `config.email` and has done since #5160 and +// before; the schema declared none of them. An author annotating +// `objectstack.config.ts` with `EmailServiceConfig` got a type error for a +// value the runtime honours. +// +// HOW THESE ASSERT, AND WHY NOT `success`. This object strips unknown keys, so +// `safeParse({ queueDelivery: true }).success` was already `true` before the +// fix — the key was simply thrown away. `success` is therefore a phantom +// check here: the fact under test is that the key is a real AUTHORING SURFACE, +// which on a strip object is observable as "it SURVIVES the parse". Every +// assertion below reads `parsed.data`, so each one is green after the +// declaration and red on a revert of it. +// ───────────────────────────────────────────────────────────────────────────── +describe('EmailServiceConfigSchema — keys the CLI reads (#5307)', () => { + it('carries queueDelivery through the parse, not into the bin', () => { + for (const queueDelivery of [true, false]) { + const parsed = EmailServiceConfigSchema.safeParse({ queueDelivery }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.queueDelivery, String(queueDelivery)).toBe(queueDelivery); + } + }); + + it('types queueDelivery as the boolean flag the CLI resolves it to', () => { + // `OS_EMAIL_QUEUE_ENABLED` is parsed into a boolean before it reaches the + // plugin, and the config half must not be the one place a string arrives. + expect(EmailServiceConfigSchema.safeParse({ queueDelivery: 'true' }).success).toBe(false); + }); + + it('carries appName through the parse', () => { + const parsed = EmailServiceConfigSchema.safeParse({ appName: 'Acme CRM' }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.appName).toBe('Acme CRM'); + }); + + it('carries defaultTemplateContext through the parse, values untouched', () => { + // Free-form by design: the CLI spreads this object into the plugin's + // render context unchanged, so declaring a closed vocabulary here would + // invent a constraint the reader does not have. + const context = { supportEmail: 'help@acme.test', year: 2026, brand: { url: 'https://acme.test' } }; + const parsed = EmailServiceConfigSchema.safeParse({ defaultTemplateContext: context }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.defaultTemplateContext).toEqual(context); + }); + + it('accepts the three together with the keys that were already declared', () => { + const parsed = EmailServiceConfigSchema.safeParse({ + provider: 'smtp', + options: { host: 'smtp.acme.test' }, + retries: 3, + queueDelivery: true, + appName: 'Acme CRM', + defaultTemplateContext: { supportEmail: 'help@acme.test' }, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data).toMatchObject({ + queueDelivery: true, + appName: 'Acme CRM', + defaultTemplateContext: { supportEmail: 'help@acme.test' }, + }); + } + }); + + it('leaves all three absent when unwritten — no defaults invented', () => { + // The runtime's defaults (inline delivery, appName 'ObjectStack') are + // resolved in `resolveEmailCapabilityArg` against env and the top-level + // config. A `.default()` here would fabricate a second answer that wins + // over neither and confuses both. + const parsed = EmailServiceConfigSchema.safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data).not.toHaveProperty('queueDelivery'); + expect(parsed.data).not.toHaveProperty('appName'); + expect(parsed.data).not.toHaveProperty('defaultTemplateContext'); + } + }); +}); diff --git a/packages/spec/src/system/email-config.zod.ts b/packages/spec/src/system/email-config.zod.ts index d565b03243..f31333cd41 100644 --- a/packages/spec/src/system/email-config.zod.ts +++ b/packages/spec/src/system/email-config.zod.ts @@ -16,6 +16,17 @@ import { lazySchema } from '../shared/lazy-schema'; * 2. `OS_EMAIL_*` environment variables (override per setting) * 3. Default → provider='log' (LogTransport, no real send) * + * `appName` is the one key whose env layer is not `OS_EMAIL_*` — it is + * `OS_APP_NAME`, because the same product name names the whole deployment, + * not just its mail. + * + * Every key here is one `resolveEmailCapabilityArg` reads (the single reader + * of `config.email`); the schema is the operator-facing contract for that + * function, so a key the runtime honours and this object omits is a type + * error on a config that boots fine — the declared ≠ implemented gap of + * #5104 (provider='smtp') and #5307 (queueDelivery / appName / + * defaultTemplateContext), both times with the spec on the lagging side. + * * SMTP delivery is built in (ADR-0012): select it with provider='smtp' * and supply the connection through `options` (host / port / secure / * user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT / @@ -91,6 +102,34 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ */ persist: z.boolean().optional().describe('Persist to sys_email (default true)'), + /** + * Deliver through the durable `sys_job_queue` path instead of inline + * (#5160). Default false — `send()` calls the transport in-process and + * returns when it has answered. + * + * When true, `send()` persists the `sys_email` row, publishes an + * `email.send.async` job referencing it and returns `status: 'queued'` + * immediately; a worker delivers that row and finalizes it in place, so a + * delivery survives a restart. `OS_EMAIL_QUEUE_ENABLED` overrides this per + * environment (`1`/`true`/`yes`/`on` ⇒ on, anything else ⇒ off). + * + * Two things it does NOT do. It adds no second retry knob: `retries` + * becomes the queue's attempt budget (`retries + 1` attempts, exponential + * backoff, then DLQ) instead of driving an in-process loop. And it is not + * a preference — declaring it here is a deployment declaration, so a boot + * with no durable `queue` service (or with `persist: false`, which leaves + * a queued job no row to deliver) FAILS on `kernel:ready` rather than + * silently delivering inline. The Settings → Mail toggle is the opposite + * trade: it degrades to inline and says so, because one save must not stop + * the mail. + */ + queueDelivery: z.boolean().optional() + .describe( + 'Deliver through the durable sys_job_queue instead of inline (or OS_EMAIL_QUEUE_ENABLED env). ' + + 'Default false. Reuses `retries` as the queue attempt budget; requires a queue service ' + + 'and sys_email persistence, else the boot fails', + ), + /** * Provider-specific extras. Free-form object the selected transport * consumes; the keys each provider reads are: @@ -108,5 +147,51 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ 'Provider-specific extras. smtp: host (required) / port / secure / user / password, ' + 'mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream', ), + + /** + * Product name templates render as `{{appName}}`, and the one piece of + * template context this schema names explicitly because the runtime also + * derives a *from-address* out of it. + * + * Resolved as `OS_APP_NAME` env → this key → the top-level `appName` of + * `objectstack.config.ts` → `'ObjectStack'`. It is seeded into + * `defaultTemplateContext` as `appName`, so setting it here is exactly + * `defaultTemplateContext: { appName: … }` with the env layer in front. + * + * When no `defaultFrom` resolves from any source, the resolved app name + * also becomes the placeholder sender — `Acme CRM` ⇒ + * `Acme CRM ` — which only ever leaves the box + * through a real transport, so configure `defaultFrom` before selecting + * one. + */ + appName: z.string().optional() + .describe( + 'Product name for template rendering ({{appName}}) — OS_APP_NAME env wins, then this, then ' + + 'the top-level config appName, then "ObjectStack". Also seeds the placeholder no-reply ' + + 'sender when no defaultFrom is configured', + ), + + /** + * Render context merged into every `sendTemplate()` call, under the + * per-call `data`. Free-form on purpose: the CLI passes this object + * through to `EmailServicePlugin` unchanged and the template engine + * resolves whatever names a template happens to reference, so there is no + * closed vocabulary here to declare — put the values your own templates + * interpolate (support address, brand URL, footer text …). + * + * `appName` is always present: it is computed first and this object is + * spread OVER it, so an `appName` written here wins over `OS_APP_NAME` + * and over the `appName` key above. That is the one place the header's + * "env overrides per setting" does not hold, measured rather than + * intended — whether it should is filed as #5448. Until it is settled, + * prefer `appName` (or the env var) for that one value and keep this map + * for everything else. + */ + defaultTemplateContext: z.record(z.string(), z.unknown()).optional() + .describe( + 'Free-form render context merged into every sendTemplate() call, under the per-call data. ' + + 'Passed through unchanged. An appName written here overrides both the appName key and ' + + 'OS_APP_NAME', + ), })); export type EmailServiceConfig = z.infer; From 558ff27cedc79980930f82daf7335593afb0a077 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:02:39 +0000 Subject: [PATCH 2/4] =?UTF-8?q?chore(spec):=20=E9=9A=8F=E8=A1=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=89=A9=20+=20=E8=A7=84=E9=81=BF=20docs=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E7=9A=84=E8=8A=B1=E6=8B=AC=E5=8F=B7=E8=BD=AC?= =?UTF-8?q?=E4=B9=89=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authorable-surface.json 记入三个新可授权键(gen:schema;check:authorable-surface 是删除棘轮,新增键不记入即"对该棘轮永久隐形") - content/docs/references/system/email-config.mdx 重新生成,属性表出现三行 - appName 的 .describe() 不再写 {{appName}}:生成器会把双花括号转义成 `{{x}` 加一个游离的 },main 上已有 3 处同样的破损(已另立 #5452), 源码留注释说明为何这里绕开 check:generated 九个 gate 全绿;gen:strictness-ledger 整体重算零 diff (台账只分诊 ui/data/automation/security/studio,system/ 不在其内)。 --- .../docs/references/system/email-config.mdx | 21 +++++++++++++++++++ packages/spec/authorable-surface.json | 3 +++ packages/spec/src/system/email-config.zod.ts | 9 +++++--- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/content/docs/references/system/email-config.mdx b/content/docs/references/system/email-config.mdx index 217b236968..a50dcd6be4 100644 --- a/content/docs/references/system/email-config.mdx +++ b/content/docs/references/system/email-config.mdx @@ -23,6 +23,24 @@ Resolution order in `serve.ts`: 3. Default → provider='log' (LogTransport, no real send) +`appName` is the one key whose env layer is not `OS_EMAIL_*` — it is + +`OS_APP_NAME`, because the same product name names the whole deployment, + +not just its mail. + +Every key here is one `resolveEmailCapabilityArg` reads (the single reader + +of `config.email`); the schema is the operator-facing contract for that + +function, so a key the runtime honours and this object omits is a type + +error on a config that boots fine — the declared ≠ implemented gap of + +#5104 (provider='smtp') and #5307 (queueDelivery / appName / + +defaultTemplateContext), both times with the spec on the lagging side. + SMTP delivery is built in (ADR-0012): select it with provider='smtp' and supply the connection through `options` (host / port / secure / @@ -82,7 +100,10 @@ const result = EmailAddressConfigSchema.parse(data); | **defaultFrom** | `{ name?: string; address: string }` | optional | | | **retries** | `integer` | optional | Retry attempts on transport throw | | **persist** | `boolean` | optional | Persist to sys_email (default true) | +| **queueDelivery** | `boolean` | optional | Deliver through the durable sys_job_queue instead of inline (or OS_EMAIL_QUEUE_ENABLED env). Default false. Reuses `retries` as the queue attempt budget; requires a queue service and sys_email persistence, else the boot fails | | **options** | `Record` | optional | Provider-specific extras. smtp: host (required) / port / secure / user / password, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream | +| **appName** | `string` | optional | Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then this, then the top-level config appName, then "ObjectStack". Also seeds the placeholder no-reply sender when no defaultFrom is configured | +| **defaultTemplateContext** | `Record` | optional | Free-form render context merged into every sendTemplate() call, under the per-call data. Passed through unchanged. An appName written here overrides both the appName key and OS_APP_NAME | --- diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index b54e5f381c..647b829966 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -5821,10 +5821,13 @@ "system/EmailAndPasswordConfig:resetPasswordTokenExpiresIn", "system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset", "system/EmailServiceConfig:apiKey", + "system/EmailServiceConfig:appName", "system/EmailServiceConfig:defaultFrom", + "system/EmailServiceConfig:defaultTemplateContext", "system/EmailServiceConfig:options", "system/EmailServiceConfig:persist", "system/EmailServiceConfig:provider", + "system/EmailServiceConfig:queueDelivery", "system/EmailServiceConfig:retries", "system/EmailTemplateDefinition:_lock", "system/EmailTemplateDefinition:_lockDocsUrl", diff --git a/packages/spec/src/system/email-config.zod.ts b/packages/spec/src/system/email-config.zod.ts index f31333cd41..cbfe17327b 100644 --- a/packages/spec/src/system/email-config.zod.ts +++ b/packages/spec/src/system/email-config.zod.ts @@ -166,9 +166,12 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ */ appName: z.string().optional() .describe( - 'Product name for template rendering ({{appName}}) — OS_APP_NAME env wins, then this, then ' - + 'the top-level config appName, then "ObjectStack". Also seeds the placeholder no-reply ' - + 'sender when no defaultFrom is configured', + // No `{{…}}` in this string: the docs generator escapes a doubled brace + // into MDX as `` `{{x}` `` plus a stray `}` (three such sites already on + // main — filed as #5452), so the name is spelled without them. + 'Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then ' + + 'this, then the top-level config appName, then "ObjectStack". Also seeds the placeholder ' + + 'no-reply sender when no defaultFrom is configured', ), /** From 662d056c7202552d332643060be5e631c68ad6c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:54:46 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test(cli):=20#5447=20=E5=B7=B2=E7=94=B1=20P?= =?UTF-8?q?R=20#5470=20=E8=90=BD=E5=9C=B0,parity=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=92=A4=E9=94=80=20persist=20=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve-email-config-parity.contract.test.ts` 曾注册唯一一条 `DECLARED_BUT_UNREAD` 豁免:`persist` 被 schema 声明却无人读取(#5447)。cli 车道的 PR #5470 (`cd2efe62a`)已合入 main —— `resolveEmailCapabilityArg` 现在经由新的 `envBooleanFlag` / `OS_EMAIL_PERSIST_ENABLED`(三态,默认仍为 ON)读取 `cfgEmail.persist`。 于是本分支的断言在 merge queue 里必然变红:它 expect `unread == ['persist']`, 而合并后的 main 上 `unread == []`。跨车道协议(#5447 评论)约定后落地方对齐, 即本分支。 对齐做法:删除豁免数组,而非留一个空数组 —— 空注册表是一种邀请,下一个 declared-but-unread 键会被直接追加进去而不必辩论,正是该条目当初要防止的 「静默豁免」。断言随之收紧为两个方向都为空,即 declared 集与 read 集相等, 也就是文件注释当初许诺的 plain set equality。豁免的来龙去脉保留在注释中。 反向验证(方向预先判定为 red,结果一致):保留旧豁免数组对合并后的 main 运行, `AssertionError: expected [] to deeply equal [ 'persist' ]` —— 这正是本次预先 规避的队列失败;撤销豁免后该文件 6 个用例全绿。 另:changeset 里「它读八个键」是 #5470 之前的读侧计数(现为九个),补时间 限定词「本次改动时」,以免这段 CHANGELOG 文案落地后失真。schema 中 `persist` 的 TSDoc「Persist to sys_email (default true)」经核对在 #5470 之后依然成立 (默认仍为 ON),故不改。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --- .changeset/email-config-runtime-read-keys.md | 4 +- ...serve-email-config-parity.contract.test.ts | 41 ++++++++++++------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.changeset/email-config-runtime-read-keys.md b/.changeset/email-config-runtime-read-keys.md index 36fbd6a371..5b2e0d6f52 100644 --- a/.changeset/email-config-runtime-read-keys.md +++ b/.changeset/email-config-runtime-read-keys.md @@ -5,8 +5,8 @@ spec: `EmailServiceConfigSchema` 补齐 CLI 实读的 `queueDelivery` / `appName` / `defaultTemplateContext` `config.email` 在全仓只有一个读者 —— `packages/cli/src/commands/serve.ts` 的 -`resolveEmailCapabilityArg`。它读八个键,`EmailServiceConfigSchema` 只声明其中五个, -差集三个已经被运行时消费多时: +`resolveEmailCapabilityArg`。本次改动时它读八个键,`EmailServiceConfigSchema` 只声明 +其中五个,差集三个已经被运行时消费多时: - `queueDelivery` —— #5160 落地的耐久队列投递开关(env 侧 `OS_EMAIL_QUEUE_ENABLED`) - `appName` —— 模板 `{{appName}}` 的产品名,兼作无 `defaultFrom` 时的兜底发件人来源 diff --git a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts index b0e95381c2..13c880855a 100644 --- a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts +++ b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts @@ -69,21 +69,32 @@ function keysDeclaredBySchema(): string[] { } /** - * `persist` is declared and NOT read — the mirror image of #5307, filed - * separately (#5447) and deliberately untouched here. + * No exemptions: both directions below are plain set equality. * - * The plugin option it names is live (`EmailServicePlugin` builds no - * `EmailPersistence` when `persist === false`), but nothing carries - * `config.email.persist` to the plugin: `resolveEmailCapabilityArg` never - * reads the key, and it is the only reader `config.email` has. So the schema - * advertises an authoring surface with no carrier — ADR-0049 enforce-or-remove - * territory, whose two answers (wire it in the resolver, or retire the key) - * are a contract decision, not a drive-by. + * There was one, and its history is the reason this paragraph stays. `persist` + * was declared and NOT read — the mirror image of #5307 — so this file shipped + * it as the single registered `DECLARED_BUT_UNREAD` entry, filed as #5447 + * rather than silently excused. The plugin option it names was already live + * (`EmailServicePlugin` builds no `EmailPersistence` when `persist === false`), + * but nothing carried `config.email.persist` to the plugin, and + * `resolveEmailCapabilityArg` is the only reader `config.email` has. So the + * schema advertised an authoring surface with no carrier: a deployment that + * wrote `email: { persist: false }` to keep bodies out of the database + * type-checked, parsed, read as configured, and went on writing every subject, + * body and recipient to `sys_email`. * - * It is listed rather than silently excused: when #5447 is settled this array - * empties and the assertion below tightens to plain set equality. + * ADR-0049 enforce-or-remove, answered "enforce" by PR #5470 (`cd2efe62a`): + * the resolver reads the key, behind a tri-state `OS_EMAIL_PERSIST_ENABLED` + * env layer, with the default still ON. That is what the exemption was waiting + * for, so the promise it carried is kept here. + * + * The array is DELETED rather than left standing empty. An empty registry is + * an invitation — the next declared-but-unread key gets appended to it instead + * of argued about, which is exactly the silent excusing the entry existed to + * prevent. With it gone, a key declared and not read is red the day it lands, + * and re-introducing an exemption means re-introducing the mechanism, in a + * diff someone has to justify. */ -const DECLARED_BUT_UNREAD = ['persist'] as const; describe('EmailServiceConfigSchema ↔ resolveEmailCapabilityArg', () => { it('declares every config.email key the resolver reads (#5307)', () => { @@ -93,10 +104,12 @@ describe('EmailServiceConfigSchema ↔ resolveEmailCapabilityArg', () => { expect(undeclared).toEqual([]); }); - it('reads every key it declares, but for the filed exemption (#5447)', () => { + it('reads every key it declares — no exemptions since #5447 landed (PR #5470)', () => { const read = new Set(keysReadFromConfigEmail()); const unread = keysDeclaredBySchema().filter((k) => !read.has(k)); - expect(unread).toEqual([...DECLARED_BUT_UNREAD]); + // Red before PR #5470 with ['persist']. With this at [] and the assertion + // above also at [], the declared set and the read set are equal. + expect(unread).toEqual([]); }); it('pins the measured key set so a rename is a conscious edit', () => { From 70ad09d8c7dc362ac795a65722be6da641e636b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:19:22 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(spec):=20appName=20=E6=96=87=E6=A1=88?= =?UTF-8?q?=E4=B8=8E=20parity=20pin=20=E6=94=B6=E5=8F=A3=E5=88=B0=20#5448?= =?UTF-8?q?=20=E8=A3=81=E5=AE=9A=E7=9A=84=E6=96=B0=E5=BA=8F(PR=20#5498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5448 已裁 direction B 并由 PR #5498 落地:`resolveEmailCapabilityArg` 现在把 `appName` 放在 context 展开之后解析,五级链为 `OS_APP_NAME` > `config.email.appName` > `defaultTemplateContext.appName` > 顶层 `appName` > `'ObjectStack'`。本分支上写于旧序之上的三处东西随之收口: - `serve-email-config-parity.contract.test.ts` 那条 pin 原本钉的是旧序 (context.appName 压过 env),现改为钉新序。它保留本文件自己的角度而非 重述 #5498 的用例:配置先过真正的 `EmailServiceConfigSchema.parse()` 再喂 读侧,因此钉住的是 #5307 新加的两个契约键既能存活 parse、又确实落在 schema 文案承诺的档位上。 - `email-config.zod.ts` 中 `appName` / `defaultTemplateContext` 的 TSDoc 与 两处 `.describe()`:旧文案写的是「写在 context 里的 appName 压过 appName 键与 OS_APP_NAME、是否合理 filed as #5448」,该事实已不成立。 `email-config.mdx` 由 `gen:docs` 整体重生成(未手改),9 个 generated 门全绿。 运行时零改动 —— `serve.ts` 未被本次改动触碰。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --- .changeset/email-config-runtime-read-keys.md | 9 ++- .../docs/references/system/email-config.mdx | 4 +- ...serve-email-config-parity.contract.test.ts | 52 +++++++++++++++--- packages/spec/src/system/email-config.zod.ts | 55 ++++++++++++------- 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/.changeset/email-config-runtime-read-keys.md b/.changeset/email-config-runtime-read-keys.md index 5b2e0d6f52..243789a9fd 100644 --- a/.changeset/email-config-runtime-read-keys.md +++ b/.changeset/email-config-runtime-read-keys.md @@ -19,8 +19,13 @@ spec: `EmailServiceConfigSchema` 补齐 CLI 实读的 `queueDelivery` / `appName 本次是把契约追平既成事实,**运行时零改动**:三个键都是 optional,不带 `.default()` (默认值由 `resolveEmailCapabilityArg` 对着 env 与顶层 config 解析,schema 再造一个只会 -多出一个谁也不赢的答案),`defaultTemplateContext` 保持自由 record —— 读侧原样透传, -声明一套读侧没有的约束等于发明契约。 +多出一个谁也不赢的答案),`defaultTemplateContext` 保持自由 record —— 除 `appName` 外 +读侧原样透传,声明一套读侧没有的约束等于发明契约。 + +`appName` 与 `defaultTemplateContext` 的 TSDoc / `.describe()` 按 #5448 已裁定的新序落笔 +(`OS_APP_NAME` > `config.email.appName` > `defaultTemplateContext.appName` > 顶层 +`appName` > `'ObjectStack'`,PR #5498 落地),因此生成的 `email-config.mdx` 属性表文案 +随之更新:此前 context 里的 `appName` 压过 env 的旧行为已不复存在,文档不再那样承诺。 同时新增跨包契约测试 `serve-email-config-parity.contract.test.ts`,把 issue 里那条手工 grep 机械化:读侧多出一个未声明的键即变红,不必再等下一次人工比对。 diff --git a/content/docs/references/system/email-config.mdx b/content/docs/references/system/email-config.mdx index a50dcd6be4..e7456e97af 100644 --- a/content/docs/references/system/email-config.mdx +++ b/content/docs/references/system/email-config.mdx @@ -102,8 +102,8 @@ const result = EmailAddressConfigSchema.parse(data); | **persist** | `boolean` | optional | Persist to sys_email (default true) | | **queueDelivery** | `boolean` | optional | Deliver through the durable sys_job_queue instead of inline (or OS_EMAIL_QUEUE_ENABLED env). Default false. Reuses `retries` as the queue attempt budget; requires a queue service and sys_email persistence, else the boot fails | | **options** | `Record` | optional | Provider-specific extras. smtp: host (required) / port / secure / user / password, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream | -| **appName** | `string` | optional | Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then this, then the top-level config appName, then "ObjectStack". Also seeds the placeholder no-reply sender when no defaultFrom is configured | -| **defaultTemplateContext** | `Record` | optional | Free-form render context merged into every sendTemplate() call, under the per-call data. Passed through unchanged. An appName written here overrides both the appName key and OS_APP_NAME | +| **appName** | `string` | optional | Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then this, then defaultTemplateContext.appName, then the top-level config appName, then "ObjectStack". Also seeds the placeholder no-reply sender when no defaultFrom is configured | +| **defaultTemplateContext** | `Record` | optional | Free-form render context merged into every sendTemplate() call, under the per-call data. Passed through unchanged except appName, which is resolved by its own chain — OS_APP_NAME and the appName key both override the value written here | --- diff --git a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts index 13c880855a..8a0fbe72f1 100644 --- a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts +++ b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts @@ -166,20 +166,54 @@ describe('a config the schema accepts reaches the plugin intact', () => { expect(options.defaultFrom).toEqual({ name: 'Acme CRM', address: 'no-reply@acme-crm.local' }); }); - it('spreads defaultTemplateContext OVER the resolved appName, as documented', () => { - // Not the precedence the header TSDoc's "env overrides per setting" line - // would predict — `OS_APP_NAME` loses to an `appName` written inside - // `defaultTemplateContext`. Measured, documented in the schema, and filed - // as #5448; pinned here so a change to it is deliberate rather than - // discovered by an operator whose branded mail says the wrong name. + it('resolves appName by the five-rung chain — env over both declared keys (#5448)', () => { + // This pin used to record the OPPOSITE: `defaultTemplateContext` was + // spread OVER the resolved value, so `OS_APP_NAME` lost to an `appName` + // written inside it. That was measured behaviour, not intent, and was + // filed as #5448 — settled direction B (the env must win, per the header's + // "override per setting") and implemented by PR #5498, which resolves + // `appName` AFTER the spread. This pin now guards the new order. + // + // Its angle is this file's own, and not a restatement of + // `serve-email-appname-precedence.test.ts`: that file feeds the resolver + // raw objects, whereas the config below goes through the real + // `EmailServiceConfigSchema.parse()` first. So what is pinned here is that + // the two keys #5307 added to the CONTRACT survive the parse AND land on + // the rungs the schema's own prose promises — a schema that renamed, + // stripped or reshaped either key would be red here even while the + // resolver's own tests stayed green. const parsed = EmailServiceConfigSchema.parse({ appName: 'From The Key', - defaultTemplateContext: { appName: 'From The Context' }, + defaultTemplateContext: { appName: 'From The Context', supportEmail: 'help@acme.test' }, }); - const { options } = resolveEmailCapabilityArg(parsed as Record, { + + // Rung 1 — all three sources distinct and present: the env var wins. + // (Three DIFFERENT values on purpose: asserting a value all three sources + // agree on would pass under any ordering, including the old one.) + const withEnv = resolveEmailCapabilityArg(parsed as Record, { OS_APP_NAME: 'From The Env', + }).options; + expect(withEnv.defaultTemplateContext).toEqual({ + appName: 'From The Env', + supportEmail: 'help@acme.test', // every other context key still spreads verbatim }); + // The blast radius #5448 named: the placeholder sender is slugged from the + // resolved name, so the envelope moves with the body or the fix is half done. + expect(withEnv.defaultFrom).toEqual({ name: 'From The Env', address: 'no-reply@from-the-env.local' }); + + // Rung 2 — no env: the declared `appName` key beats the context form. + // Under the old spread-over order this answered 'From The Context' too, + // so this half discriminates the two orders even without an env var set. + const noEnv = resolveEmailCapabilityArg(parsed as Record, {}).options; + expect(noEnv.defaultTemplateContext).toMatchObject({ appName: 'From The Key' }); - expect(options.defaultTemplateContext).toMatchObject({ appName: 'From The Context' }); + // Rung 3 — the context form is still IN the chain, not dropped behind the + // dedicated key: a config that declares only this shape keeps its name + // rather than being demoted to 'ObjectStack'. + const contextOnly = EmailServiceConfigSchema.parse({ + defaultTemplateContext: { appName: 'From The Context' }, + }); + expect(resolveEmailCapabilityArg(contextOnly as Record, {}).options + .defaultTemplateContext).toMatchObject({ appName: 'From The Context' }); }); }); diff --git a/packages/spec/src/system/email-config.zod.ts b/packages/spec/src/system/email-config.zod.ts index cbfe17327b..4a47751820 100644 --- a/packages/spec/src/system/email-config.zod.ts +++ b/packages/spec/src/system/email-config.zod.ts @@ -153,10 +153,18 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ * template context this schema names explicitly because the runtime also * derives a *from-address* out of it. * - * Resolved as `OS_APP_NAME` env → this key → the top-level `appName` of - * `objectstack.config.ts` → `'ObjectStack'`. It is seeded into - * `defaultTemplateContext` as `appName`, so setting it here is exactly - * `defaultTemplateContext: { appName: … }` with the env layer in front. + * Resolved on a five-rung chain: `OS_APP_NAME` env → this key → + * `defaultTemplateContext.appName` → the top-level `appName` of + * `objectstack.config.ts` → `'ObjectStack'`. The resolved value is then + * written into `defaultTemplateContext` as `appName`, so templates read one + * answer whichever rung supplied it. + * + * This key and `defaultTemplateContext: { appName: … }` are therefore NOT + * interchangeable — this one is the higher rung, and both lose to the env + * var. That ordering was settled by #5448 (implemented in PR #5498): before + * it, the whole context was spread OVER the resolved value, which made + * `OS_APP_NAME` inert for any config that spelled the context form — the one + * per-environment lever over a repo-pinned config, silently doing nothing. * * When no `defaultFrom` resolves from any source, the resolved app name * also becomes the placeholder sender — `Acme CRM` ⇒ @@ -170,31 +178,40 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ // into MDX as `` `{{x}` `` plus a stray `}` (three such sites already on // main — filed as #5452), so the name is spelled without them. 'Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then ' - + 'this, then the top-level config appName, then "ObjectStack". Also seeds the placeholder ' - + 'no-reply sender when no defaultFrom is configured', + + 'this, then defaultTemplateContext.appName, then the top-level config appName, then ' + + '"ObjectStack". Also seeds the placeholder no-reply sender when no defaultFrom is configured', ), /** * Render context merged into every `sendTemplate()` call, under the * per-call `data`. Free-form on purpose: the CLI passes this object - * through to `EmailServicePlugin` unchanged and the template engine - * resolves whatever names a template happens to reference, so there is no - * closed vocabulary here to declare — put the values your own templates - * interpolate (support address, brand URL, footer text …). + * through to `EmailServicePlugin` as written — `appName` excepted, see + * below — and the template engine resolves whatever names a template + * happens to reference, so there is no closed vocabulary here to declare — + * put the values your own templates interpolate (support address, brand + * URL, footer text …). + * + * One key is not passed through as written: `appName`. It is always + * present in the delivered context, and its value comes from the chain on + * the `appName` key above — `OS_APP_NAME` → `appName` → this map's + * `appName` → the top-level config `appName` → `'ObjectStack'`. So writing + * it here still works (it is the third rung, and a config that spells only + * this form keeps its name), but the env var and the dedicated key both + * override it. * - * `appName` is always present: it is computed first and this object is - * spread OVER it, so an `appName` written here wins over `OS_APP_NAME` - * and over the `appName` key above. That is the one place the header's - * "env overrides per setting" does not hold, measured rather than - * intended — whether it should is filed as #5448. Until it is settled, - * prefer `appName` (or the env var) for that one value and keep this map - * for everything else. + * It used to be the other way round: the resolver computed the value and + * then spread this whole map OVER it, which made `OS_APP_NAME` inert and + * broke the header's "env overrides per setting" on exactly one key. + * #5448 settled that the env must win (implemented in PR #5498), and the + * exception is gone. Every OTHER key here has no env or dedicated-config + * carrier, so it remains the only source for itself and reaches templates + * verbatim. */ defaultTemplateContext: z.record(z.string(), z.unknown()).optional() .describe( 'Free-form render context merged into every sendTemplate() call, under the per-call data. ' - + 'Passed through unchanged. An appName written here overrides both the appName key and ' - + 'OS_APP_NAME', + + 'Passed through unchanged except appName, which is resolved by its own chain — ' + + 'OS_APP_NAME and the appName key both override the value written here', ), })); export type EmailServiceConfig = z.infer;