Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/29485-email-template-baseurl-describe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': patch
---

docs(spec): `email.defaultTemplateContext` now names `baseUrl` beside `appName` as a key the runtime resolves itself

Clause-②: no
Metadata-protocol: none

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.

Text only: the key stays a free-form record, and nothing an author can write changes.
4 changes: 2 additions & 2 deletions content/docs/references/system/email-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const result = EmailAddressConfigSchema.parse(data);
| **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 |
| **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 |

### Nested Shape: `EmailServiceConfig.defaultFrom`

Expand All @@ -122,7 +122,7 @@ 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 |
| **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 |
| **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 |
| **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 |

### Nested Shape: `StackEmailConfig.defaultFrom`
Expand Down
52 changes: 31 additions & 21 deletions packages/spec/src/system/email-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,33 +196,43 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({
/**
* 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 …).
* through to `EmailServicePlugin` as written — `appName` and `baseUrl`
* 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.
* Two keys are not passed through as written: `appName` and `baseUrl`.
* Both are always present in the delivered context, each resolved by its
* own chain, env first, with the value written here as one rung:
* - `appName` 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.
* - `baseUrl` is the deployment's address, for absolute links in a
* template — `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`) → `OS_BASE_URL` →
* this map's `baseUrl` → `http://localhost:` plus the serve port
* (`OS_PORT`, legacy `PORT`, else `3000`), with any trailing slash
* trimmed. Writing it here is the fallback for a deployment that sets
* neither env var.
* A send's own `data` still wins over both, as it does over every key here.
*
* 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.
* It used to be the other way round for `appName`: 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 `baseUrl` follows the same order (#29095). 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',
+ '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',
),
}));
export type EmailServiceConfig = z.input<typeof EmailServiceConfigSchema>;
Expand Down
Loading