Skip to content

Commit ebbb5b8

Browse files
docs(spec): email.defaultTemplateContext names baseUrl beside appName as a resolved key (#29539)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8CSZ7Bi88n61fRQQZfZQA16)_ Fixes #29485 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`email.defaultTemplateContext` 的 `.describe()` 与 TSDoc 把 `baseUrl` 和 `appName` 并列为运行时自行解析的键,并写出 `baseUrl` 的解析链;重新生成 `email-config.mdx`。 - 为什么改:#29478 已让邮件插件解析 `baseUrl`,规范文字仍说只有 `appName` 例外,读 `/meta/types` 或参考页的作者会被误导。 - 风险与回滚:只改文字,无 schema 变化,键仍是自由 record;回滚即 revert 本 PR。 - 先看:`packages/spec/src/system/email-config.zod.ts`(`defaultTemplateContext` 的文档块与 describe)。 - 两分钟验证:`pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`,预期 14 个产物全部最新。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/system/email-config.zod.ts`: the `defaultTemplateContext` docblock and `.describe()` now name `baseUrl` beside `appName` as the two keys the runtime resolves itself. The `baseUrl` chain is read from the merged plugin code (`resolveDeploymentBaseUrl` in `packages/plugins/plugin-email/src/capability-arg.ts`, landed by #29478 at `73c24394e2`): `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`), then `OS_BASE_URL`, then `defaultTemplateContext.baseUrl`, then `http://localhost:` plus the serve port (`OS_PORT`, legacy `PORT`, else `3000`), with any trailing slash trimmed. The docblock also records that a send's own `data` wins over both keys (`EmailService` spreads the send's data over this context). - `content/docs/references/system/email-config.mdx`: regenerated with `pnpm --filter @objectstack/spec gen:docs` (two table rows: the service schema and the stack `email` schema). - `.changeset/29485-email-template-baseurl-describe.md`: `@objectstack/spec` patch. `packages/spec` ships `src/**/*.zod.ts` as source, so the docblock publishes. No schema change: the key stays `z.record(z.string(), z.unknown()).optional()`, as the card requires. ## Why Ruling A on #29095 kept the spec out of that card and left this text to the spec lane. The describe still read "Passed through unchanged except appName", which is now incomplete. ## Tests No test pinned the old wording (`git grep` over the tree for the old describe and docblock sentences: only the generated mdx hit). Local runs on head `cc250df8`: - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system`: 66 files, 2096 tests passed. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date after `gen:docs`. ## Acceptance notes - The file-top docblock of `email-config.zod.ts` still says "`appName` is the one key whose env layer is not `OS_EMAIL_*`". That sentence is about the schema's own keys, and `baseUrl` is a key inside `defaultTemplateContext`, not a schema key, so it stays true and is outside the claimed file surface. Noted, not filed. ## Verification `node scripts/pm/dispatch-gates.mjs --commands` derived 97 gates for this diff on `cc250df8`; all were run and reconciled with `--ran`: 96 exit 0, 1 NOT MEASURED. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: exit 3, it needs every package built; left to CI. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 (pinned fixture commit not in the shallow clone); after fetching that commit it exited 0 (48 cases). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. Not run locally: the repo-wide lint run is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbafZoFFmxkFb9fzVPa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff827bc commit ebbb5b8

3 files changed

Lines changed: 45 additions & 23 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): `email.defaultTemplateContext` now names `baseUrl` beside `appName` as a key the runtime resolves itself
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `.describe()` and TSDoc on `EmailServiceConfigSchema.defaultTemplateContext` said every key is passed through as written except `appName`. Since the email plugin started resolving the deployment address for templates, `baseUrl` is resolved too, so the text now names both keys and gives `baseUrl` its chain: `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`), then `OS_BASE_URL`, then the value written in `defaultTemplateContext.baseUrl`, then the local `http://localhost` default on the serve port, with any trailing slash trimmed. A send's own data still wins over both keys.
11+
12+
Text only: the key stays a free-form record, and nothing an author can write changes.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const result = EmailAddressConfigSchema.parse(data);
9898
| **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 |
9999
| **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 |
100100
| **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 |
101-
| **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 |
101+
| **defaultTemplateContext** | `Record<string, any>` | optional | Free-form render context merged into every sendTemplate() call, under the per-call data. Passed through unchanged except appName and baseUrl, each resolved by its own chain — OS_APP_NAME and the appName key both override appName written here; baseUrl takes OS_AUTH_URL, then OS_BASE_URL, then the value written here, then the local http://localhost default, with no trailing slash |
102102

103103
### Nested Shape: `EmailServiceConfig.defaultFrom`
104104

@@ -122,7 +122,7 @@ const result = EmailAddressConfigSchema.parse(data);
122122
| **persist** | `boolean` | optional | Persist to sys_email (default true) |
123123
| **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 |
124124
| **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 |
125-
| **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 |
125+
| **defaultTemplateContext** | `Record<string, any>` | optional | Free-form render context merged into every sendTemplate() call, under the per-call data. Passed through unchanged except appName and baseUrl, each resolved by its own chain — OS_APP_NAME and the appName key both override appName written here; baseUrl takes OS_AUTH_URL, then OS_BASE_URL, then the value written here, then the local http://localhost default, with no trailing slash |
126126
| **options** | `{ host?: string; port?: integer; secure?: boolean; user?: string; … }` | optional | Provider-specific extras, non-secret members only. smtp: host / port / secure / user, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER (the password is OS_EMAIL_SMTP_PASSWORD only). postmark: messageStream |
127127

128128
### Nested Shape: `StackEmailConfig.defaultFrom`

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

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -196,33 +196,43 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({
196196
/**
197197
* Render context merged into every `sendTemplate()` call, under the
198198
* per-call `data`. Free-form on purpose: the CLI passes this object
199-
* through to `EmailServicePlugin` as written — `appName` excepted, see
200-
* below — and the template engine resolves whatever names a template
201-
* happens to reference, so there is no closed vocabulary here to declare —
202-
* put the values your own templates interpolate (support address, brand
203-
* URL, footer text …).
199+
* through to `EmailServicePlugin` as written — `appName` and `baseUrl`
200+
* excepted, see below — and the template engine resolves whatever names a
201+
* template happens to reference, so there is no closed vocabulary here to
202+
* declare — put the values your own templates interpolate (support address,
203+
* brand URL, footer text …).
204204
*
205-
* One key is not passed through as written: `appName`. It is always
206-
* present in the delivered context, and its value comes from the chain on
207-
* the `appName` key above — `OS_APP_NAME` → `appName` → this map's
208-
* `appName` → the top-level config `appName` → `'ObjectStack'`. So writing
209-
* it here still works (it is the third rung, and a config that spells only
210-
* this form keeps its name), but the env var and the dedicated key both
211-
* override it.
205+
* Two keys are not passed through as written: `appName` and `baseUrl`.
206+
* Both are always present in the delivered context, each resolved by its
207+
* own chain, env first, with the value written here as one rung:
208+
* - `appName` comes from the chain on the `appName` key above —
209+
* `OS_APP_NAME` → `appName` → this map's `appName` → the top-level
210+
* config `appName` → `'ObjectStack'`. So writing it here still works
211+
* (it is the third rung, and a config that spells only this form keeps
212+
* its name), but the env var and the dedicated key both override it.
213+
* - `baseUrl` is the deployment's address, for absolute links in a
214+
* template — `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`) → `OS_BASE_URL` →
215+
* this map's `baseUrl` → `http://localhost:` plus the serve port
216+
* (`OS_PORT`, legacy `PORT`, else `3000`), with any trailing slash
217+
* trimmed. Writing it here is the fallback for a deployment that sets
218+
* neither env var.
219+
* A send's own `data` still wins over both, as it does over every key here.
212220
*
213-
* It used to be the other way round: the resolver computed the value and
214-
* then spread this whole map OVER it, which made `OS_APP_NAME` inert and
215-
* broke the header's "env overrides per setting" on exactly one key.
216-
* #5448 settled that the env must win (implemented in PR #5498), and the
217-
* exception is gone. Every OTHER key here has no env or dedicated-config
218-
* carrier, so it remains the only source for itself and reaches templates
219-
* verbatim.
221+
* It used to be the other way round for `appName`: the resolver computed
222+
* the value and then spread this whole map OVER it, which made
223+
* `OS_APP_NAME` inert and broke the header's "env overrides per setting"
224+
* on exactly one key. #5448 settled that the env must win (implemented in
225+
* PR #5498), and `baseUrl` follows the same order (#29095). Every OTHER key
226+
* here has no env or dedicated-config carrier, so it remains the only
227+
* source for itself and reaches templates verbatim.
220228
*/
221229
defaultTemplateContext: z.record(z.string(), z.unknown()).optional()
222230
.describe(
223231
'Free-form render context merged into every sendTemplate() call, under the per-call data. '
224-
+ 'Passed through unchanged except appName, which is resolved by its own chain — '
225-
+ 'OS_APP_NAME and the appName key both override the value written here',
232+
+ 'Passed through unchanged except appName and baseUrl, each resolved by its own chain — '
233+
+ 'OS_APP_NAME and the appName key both override appName written here; baseUrl takes '
234+
+ 'OS_AUTH_URL, then OS_BASE_URL, then the value written here, then the local '
235+
+ 'http://localhost default, with no trailing slash',
226236
),
227237
}));
228238
export type EmailServiceConfig = z.input<typeof EmailServiceConfigSchema>;

0 commit comments

Comments
 (0)