Skip to content

Commit 70ad09d

Browse files
committed
docs(spec): appName 文案与 parity pin 收口到 #5448 裁定的新序(PR #5498)
#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
1 parent a847456 commit 70ad09d

4 files changed

Lines changed: 88 additions & 32 deletions

File tree

.changeset/email-config-runtime-read-keys.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ spec: `EmailServiceConfigSchema` 补齐 CLI 实读的 `queueDelivery` / `appName
1919

2020
本次是把契约追平既成事实,**运行时零改动**:三个键都是 optional,不带 `.default()`
2121
(默认值由 `resolveEmailCapabilityArg` 对着 env 与顶层 config 解析,schema 再造一个只会
22-
多出一个谁也不赢的答案),`defaultTemplateContext` 保持自由 record —— 读侧原样透传,
23-
声明一套读侧没有的约束等于发明契约。
22+
多出一个谁也不赢的答案),`defaultTemplateContext` 保持自由 record —— 除 `appName`
23+
读侧原样透传,声明一套读侧没有的约束等于发明契约。
24+
25+
`appName``defaultTemplateContext` 的 TSDoc / `.describe()`#5448 已裁定的新序落笔
26+
(`OS_APP_NAME` > `config.email.appName` > `defaultTemplateContext.appName` > 顶层
27+
`appName` > `'ObjectStack'`,PR #5498 落地),因此生成的 `email-config.mdx` 属性表文案
28+
随之更新:此前 context 里的 `appName` 压过 env 的旧行为已不复存在,文档不再那样承诺。
2429

2530
同时新增跨包契约测试 `serve-email-config-parity.contract.test.ts`,把 issue 里那条手工
2631
grep 机械化:读侧多出一个未声明的键即变红,不必再等下一次人工比对。

content/docs/references/system/email-config.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ const result = EmailAddressConfigSchema.parse(data);
102102
| **persist** | `boolean` | optional | Persist to sys_email (default true) |
103103
| **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 |
104104
| **options** | `Record<string, any>` | optional | Provider-specific extras. smtp: host (required) / port / secure / user / password, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream |
105-
| **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 |
106-
| **defaultTemplateContext** | `Record<string, any>` | 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 |
105+
| **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 |
106+
| **defaultTemplateContext** | `Record<string, any>` | 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 |
107107

108108

109109
---

packages/cli/src/commands/serve-email-config-parity.contract.test.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,54 @@ describe('a config the schema accepts reaches the plugin intact', () => {
166166
expect(options.defaultFrom).toEqual({ name: 'Acme CRM', address: 'no-reply@acme-crm.local' });
167167
});
168168

169-
it('spreads defaultTemplateContext OVER the resolved appName, as documented', () => {
170-
// Not the precedence the header TSDoc's "env overrides per setting" line
171-
// would predict — `OS_APP_NAME` loses to an `appName` written inside
172-
// `defaultTemplateContext`. Measured, documented in the schema, and filed
173-
// as #5448; pinned here so a change to it is deliberate rather than
174-
// discovered by an operator whose branded mail says the wrong name.
169+
it('resolves appName by the five-rung chain — env over both declared keys (#5448)', () => {
170+
// This pin used to record the OPPOSITE: `defaultTemplateContext` was
171+
// spread OVER the resolved value, so `OS_APP_NAME` lost to an `appName`
172+
// written inside it. That was measured behaviour, not intent, and was
173+
// filed as #5448 — settled direction B (the env must win, per the header's
174+
// "override per setting") and implemented by PR #5498, which resolves
175+
// `appName` AFTER the spread. This pin now guards the new order.
176+
//
177+
// Its angle is this file's own, and not a restatement of
178+
// `serve-email-appname-precedence.test.ts`: that file feeds the resolver
179+
// raw objects, whereas the config below goes through the real
180+
// `EmailServiceConfigSchema.parse()` first. So what is pinned here is that
181+
// the two keys #5307 added to the CONTRACT survive the parse AND land on
182+
// the rungs the schema's own prose promises — a schema that renamed,
183+
// stripped or reshaped either key would be red here even while the
184+
// resolver's own tests stayed green.
175185
const parsed = EmailServiceConfigSchema.parse({
176186
appName: 'From The Key',
177-
defaultTemplateContext: { appName: 'From The Context' },
187+
defaultTemplateContext: { appName: 'From The Context', supportEmail: 'help@acme.test' },
178188
});
179-
const { options } = resolveEmailCapabilityArg(parsed as Record<string, any>, {
189+
190+
// Rung 1 — all three sources distinct and present: the env var wins.
191+
// (Three DIFFERENT values on purpose: asserting a value all three sources
192+
// agree on would pass under any ordering, including the old one.)
193+
const withEnv = resolveEmailCapabilityArg(parsed as Record<string, any>, {
180194
OS_APP_NAME: 'From The Env',
195+
}).options;
196+
expect(withEnv.defaultTemplateContext).toEqual({
197+
appName: 'From The Env',
198+
supportEmail: 'help@acme.test', // every other context key still spreads verbatim
181199
});
200+
// The blast radius #5448 named: the placeholder sender is slugged from the
201+
// resolved name, so the envelope moves with the body or the fix is half done.
202+
expect(withEnv.defaultFrom).toEqual({ name: 'From The Env', address: 'no-reply@from-the-env.local' });
203+
204+
// Rung 2 — no env: the declared `appName` key beats the context form.
205+
// Under the old spread-over order this answered 'From The Context' too,
206+
// so this half discriminates the two orders even without an env var set.
207+
const noEnv = resolveEmailCapabilityArg(parsed as Record<string, any>, {}).options;
208+
expect(noEnv.defaultTemplateContext).toMatchObject({ appName: 'From The Key' });
182209

183-
expect(options.defaultTemplateContext).toMatchObject({ appName: 'From The Context' });
210+
// Rung 3 — the context form is still IN the chain, not dropped behind the
211+
// dedicated key: a config that declares only this shape keeps its name
212+
// rather than being demoted to 'ObjectStack'.
213+
const contextOnly = EmailServiceConfigSchema.parse({
214+
defaultTemplateContext: { appName: 'From The Context' },
215+
});
216+
expect(resolveEmailCapabilityArg(contextOnly as Record<string, any>, {}).options
217+
.defaultTemplateContext).toMatchObject({ appName: 'From The Context' });
184218
});
185219
});

packages/spec/src/system/email-config.zod.ts

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,18 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({
153153
* template context this schema names explicitly because the runtime also
154154
* derives a *from-address* out of it.
155155
*
156-
* Resolved as `OS_APP_NAME` env → this key → the top-level `appName` of
157-
* `objectstack.config.ts` → `'ObjectStack'`. It is seeded into
158-
* `defaultTemplateContext` as `appName`, so setting it here is exactly
159-
* `defaultTemplateContext: { appName: … }` with the env layer in front.
156+
* Resolved on a five-rung chain: `OS_APP_NAME` env → this key →
157+
* `defaultTemplateContext.appName` → the top-level `appName` of
158+
* `objectstack.config.ts` → `'ObjectStack'`. The resolved value is then
159+
* written into `defaultTemplateContext` as `appName`, so templates read one
160+
* answer whichever rung supplied it.
161+
*
162+
* This key and `defaultTemplateContext: { appName: … }` are therefore NOT
163+
* interchangeable — this one is the higher rung, and both lose to the env
164+
* var. That ordering was settled by #5448 (implemented in PR #5498): before
165+
* it, the whole context was spread OVER the resolved value, which made
166+
* `OS_APP_NAME` inert for any config that spelled the context form — the one
167+
* per-environment lever over a repo-pinned config, silently doing nothing.
160168
*
161169
* When no `defaultFrom` resolves from any source, the resolved app name
162170
* also becomes the placeholder sender — `Acme CRM` ⇒
@@ -170,31 +178,40 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({
170178
// into MDX as `` `{{x}` `` plus a stray `}` (three such sites already on
171179
// main — filed as #5452), so the name is spelled without them.
172180
'Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then '
173-
+ 'this, then the top-level config appName, then "ObjectStack". Also seeds the placeholder '
174-
+ 'no-reply sender when no defaultFrom is configured',
181+
+ 'this, then defaultTemplateContext.appName, then the top-level config appName, then '
182+
+ '"ObjectStack". Also seeds the placeholder no-reply sender when no defaultFrom is configured',
175183
),
176184

177185
/**
178186
* Render context merged into every `sendTemplate()` call, under the
179187
* per-call `data`. Free-form on purpose: the CLI passes this object
180-
* through to `EmailServicePlugin` unchanged and the template engine
181-
* resolves whatever names a template happens to reference, so there is no
182-
* closed vocabulary here to declare — put the values your own templates
183-
* interpolate (support address, brand URL, footer text …).
188+
* through to `EmailServicePlugin` as written — `appName` excepted, see
189+
* below — and the template engine resolves whatever names a template
190+
* happens to reference, so there is no closed vocabulary here to declare —
191+
* put the values your own templates interpolate (support address, brand
192+
* URL, footer text …).
193+
*
194+
* One key is not passed through as written: `appName`. It is always
195+
* present in the delivered context, and its value comes from the chain on
196+
* the `appName` key above — `OS_APP_NAME` → `appName` → this map's
197+
* `appName` → the top-level config `appName` → `'ObjectStack'`. So writing
198+
* it here still works (it is the third rung, and a config that spells only
199+
* this form keeps its name), but the env var and the dedicated key both
200+
* override it.
184201
*
185-
* `appName` is always present: it is computed first and this object is
186-
* spread OVER it, so an `appName` written here wins over `OS_APP_NAME`
187-
* and over the `appName` key above. That is the one place the header's
188-
* "env overrides per setting" does not hold, measured rather than
189-
* intended — whether it should is filed as #5448. Until it is settled,
190-
* prefer `appName` (or the env var) for that one value and keep this map
191-
* for everything else.
202+
* It used to be the other way round: the resolver computed the value and
203+
* then spread this whole map OVER it, which made `OS_APP_NAME` inert and
204+
* broke the header's "env overrides per setting" on exactly one key.
205+
* #5448 settled that the env must win (implemented in PR #5498), and the
206+
* exception is gone. Every OTHER key here has no env or dedicated-config
207+
* carrier, so it remains the only source for itself and reaches templates
208+
* verbatim.
192209
*/
193210
defaultTemplateContext: z.record(z.string(), z.unknown()).optional()
194211
.describe(
195212
'Free-form render context merged into every sendTemplate() call, under the per-call data. '
196-
+ 'Passed through unchanged. An appName written here overrides both the appName key and '
197-
+ 'OS_APP_NAME',
213+
+ 'Passed through unchanged except appName, which is resolved by its own chain — '
214+
+ 'OS_APP_NAME and the appName key both override the value written here',
198215
),
199216
}));
200217
export type EmailServiceConfig = z.infer<typeof EmailServiceConfigSchema>;

0 commit comments

Comments
 (0)