Skip to content
Merged
33 changes: 33 additions & 0 deletions .changeset/email-config-runtime-read-keys.md
Original file line numberDiff line numberDiff line change
@@ -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
21 changes: 21 additions & 0 deletions content/docs/references/system/email-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 /
Expand DownExpand Up@@ -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<string, any>` | 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<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 |


---
Expand Down
219 changes: 219 additions & 0 deletions packages/cli/src/commands/serve-email-config-parity.contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, any>, {});

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<string, any>, {});

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<string, any>, {
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<string, any>, {}).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<string, any>, {}).options
.defaultTemplateContext).toMatchObject({ appName: 'From The Context' });
});
});
3 changes: 3 additions & 0 deletions packages/spec/authorable-surface.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading