diff --git a/.changeset/email-config-runtime-read-keys.md b/.changeset/email-config-runtime-read-keys.md new file mode 100644 index 0000000000..243789a9fd --- /dev/null +++ b/.changeset/email-config-runtime-read-keys.md @@ -0,0 +1,33 @@ +--- +'@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 —— 除 `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 机械化:读侧多出一个未声明的键即变红,不必再等下一次人工比对。 + +Fixes #5307 diff --git a/content/docs/references/system/email-config.mdx b/content/docs/references/system/email-config.mdx index 217b236968..e7456e97af 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 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 new file mode 100644 index 0000000000..8a0fbe72f1 --- /dev/null +++ b/packages/cli/src/commands/serve-email-config-parity.contract.test.ts @@ -0,0 +1,219 @@ +// 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(); +} + +/** + * No exemptions: both directions below are plain set equality. + * + * 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`. + * + * 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. + */ + +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 — no exemptions since #5447 landed (PR #5470)', () => { + const read = new Set(keysReadFromConfigEmail()); + const unread = keysDeclaredBySchema().filter((k) => !read.has(k)); + // 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', () => { + 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('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', supportEmail: 'help@acme.test' }, + }); + + // 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' }); + + // 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/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.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..4a47751820 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,71 @@ 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 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` ⇒ + * `Acme CRM ` — which only ever leaves the box + * through a real transport, so configure `defaultFrom` before selecting + * one. + */ + appName: z.string().optional() + .describe( + // 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 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` 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. + * + * 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 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;