Skip to content

Commit abff9a0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-xi96xf
2 parents b6d1ad0 + 86d36e9 commit abff9a0

10 files changed

Lines changed: 367 additions & 6 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
---
4+
5+
feat(plugin-email): every email template can read the deployment's address as `{{baseUrl}}`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The default template context gains a fixed `baseUrl` key beside `appName`, so a template can write an absolute link to a record or an app route: `{{baseUrl}}/crm_deal/{{deal.id}}`. It is resolved by the chain `os serve` uses for the deployment's address, env first: `OS_AUTH_URL` (or its legacy `BETTER_AUTH_URL`), then `OS_BASE_URL`, then `config.email.defaultTemplateContext.baseUrl`, then `http://localhost:<port>` (`OS_PORT`, `PORT`, `3000`). It never ends in a slash. A send that passes its own `baseUrl` in `data` still wins.
11+
12+
No new config key and no spec change: the template context stays a free-form record. In `bodyHtml` the prefix may sit in element text but not inside an `href` (the hole-placement rule refuses that); pass a whole URL as a `type: 'url'` variable for an `<a>` link.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/core': patch
3+
---
4+
5+
fix(core): the view projection harvest reads the CEL optional-field spelling `record.?x` / `data.?x`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A list view adds to its `$select` the fields its predicates read (row and bulk action `visible` / `disabled`, the object's actions, the CRUD `visibleWhen` / `disabledWhen` overrides and conditional formatting), so a gate on a field no column shows still finds that field on the row. The harvester only knew `record.x` and `data.x`. A predicate written with the CEL optional selector, `record.?status.orValue('')`, left `status` out of the projection, so on a row that did not show `status` the selector always answered "absent" and the action behaved as if the field were empty.
11+
12+
`collectPredicateFieldRefs` now reads `record.?x` and `data.?x` as the field `x`, beside the plain spelling. Nothing else changes: a harvested name is still projected only when the object declares it (or it is a platform column) and the principal may read it. A selector that names no field (`record.?` alone, `record.?.x`) and the JavaScript spelling `record?.x` still contribute nothing.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,36 @@ Three properties of the renderer to author around:
174174
rendering by design; branching belongs in the caller, which passes in the
175175
already-decided values.
176176

177+
## What every template can read: `appName` and `baseUrl`
178+
179+
Every send's `data` is laid over a default context the deployment supplies, so
180+
two keys are always there unless the send passes its own:
181+
182+
- `{{appName}}` — the product name (`OS_APP_NAME`, then `config.email.appName`,
183+
then `config.email.defaultTemplateContext.appName`, then the stack's
184+
`appName`, then `ObjectStack`).
185+
- `{{baseUrl}}` — the address this deployment is reached at, never ending in a
186+
slash: `OS_AUTH_URL`, then `OS_BASE_URL`, then
187+
`config.email.defaultTemplateContext.baseUrl`, then the local
188+
`http://localhost:<port>`. It is the same address `os serve` resolves for the
189+
deployment, so set `OS_AUTH_URL` (or `OS_BASE_URL`) to the public origin and
190+
the mail's links follow it.
191+
192+
`baseUrl` is only the prefix: the path after it is your application's own — a
193+
record page or an app route, whatever your Console or front end serves:
194+
195+
```ts
196+
bodyText: 'Deal {{deal.name}} is yours: {{baseUrl}}/crm_deal/{{deal.id}}',
197+
```
198+
199+
That renders `https://crm.example.com/crm_deal/42`, which mail clients show as
200+
a link. In `bodyHtml` the same text may sit in element text, but not inside an
201+
`href`: a hole in a URL attribute is refused by the placement rule above, and
202+
`{{{baseUrl}}}/…` is not the whole-value `url` shape either. For an `<a>`
203+
link in `bodyHtml`, pass the whole URL as a variable declared `type: 'url'`
204+
(`<a href="{{{deal_url}}}">`). A send that passes its own `baseUrl` in `data`
205+
overrides the default.
206+
177207
## Declared variables
178208

179209
`variables` documents the holes: each entry has a `name` (the path as written in

‎packages/cli/src/commands/serve-email-appname-precedence.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ describe('resolveEmailCapabilityArg — defaultTemplateContext.appName precedenc
134134
);
135135
expect(options.defaultTemplateContext).toEqual({
136136
appName: 'Acme Prod',
137+
// The deployment address, resolved beside appName (no env or config link
138+
// here, so the local default).
139+
baseUrl: 'http://localhost:3000',
137140
supportEmail: 'help@acme.test',
138141
brandColor: '#0af',
139142
provider: 'this is template data, not the transport',
@@ -145,11 +148,11 @@ describe('resolveEmailCapabilityArg — defaultTemplateContext.appName precedenc
145148
expect(options.provider).toBe('log');
146149
});
147150

148-
it('still emits a context with only appName when no context was configured', () => {
151+
it('still emits a context with only the resolved keys when no context was configured', () => {
149152
// The shape existing deployments get. Absent config must not start
150153
// producing an empty or missing context object.
151154
expect(resolveEmailCapabilityArg({}, { OS_APP_NAME: 'Solo' }).options.defaultTemplateContext)
152-
.toEqual({ appName: 'Solo' });
155+
.toEqual({ appName: 'Solo', baseUrl: 'http://localhost:3000' });
153156
});
154157
});
155158

@@ -215,14 +218,14 @@ describe('the resolved appName reaches the service that renders templates (#5448
215218
{ defaultTemplateContext: { appName: 'Acme Dev', supportEmail: 'help@acme.test' } },
216219
{ OS_APP_NAME: 'Acme' },
217220
);
218-
expect(context).toEqual({ appName: 'Acme', supportEmail: 'help@acme.test' });
221+
expect(context).toEqual({ appName: 'Acme', baseUrl: 'http://localhost:3000', supportEmail: 'help@acme.test' });
219222
}, 60_000);
220223

221224
it('carries the context-only appName when no env or dedicated key exists', async () => {
222225
const context = await contextReachingTheService(
223226
{ defaultTemplateContext: { appName: 'Context Only' } },
224227
{},
225228
);
226-
expect(context).toEqual({ appName: 'Context Only' });
229+
expect(context).toEqual({ appName: 'Context Only', baseUrl: 'http://localhost:3000' });
227230
}, 60_000);
228231
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The email template context's `baseUrl` is the address `serve` resolves for
5+
* this deployment — the parity pin the mirror in `@objectstack/plugin-email`'s
6+
* `capability-arg.ts` (`resolveDeploymentBaseUrl`) names.
7+
*
8+
* The source is `resolveAuthBaseUrl` here in `serve.ts`; the email package
9+
* cannot import it (this package depends on that one), so it carries a copy of
10+
* the chain. This file is where both are reachable, and it drives both over
11+
* the same environment: when either chain moves — a variable added, renamed,
12+
* reordered, or an empty value read another way — one of these rows goes red.
13+
* The email reader is reached through the call `serve` makes for every
14+
* capability provider (`resolveCapabilityArgument`, `@objectstack/core`), so
15+
* the row asserts what a served boot's mail renders with.
16+
*
17+
* Rows carry no `config.email`: the config link sits between `OS_BASE_URL` and
18+
* the local default in the email chain only (the auth chain has no config
19+
* carrier), and is pinned in the email package's own test.
20+
*/
21+
22+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
23+
import { resolveCapabilityArgument } from '@objectstack/core';
24+
import * as pluginEmail from '@objectstack/plugin-email';
25+
import { resolveAuthBaseUrl } from './serve.js';
26+
27+
const TOUCHED = ['OS_AUTH_URL', 'BETTER_AUTH_URL', 'OS_BASE_URL', 'OS_PORT', 'PORT'] as const;
28+
let saved: Record<string, string | undefined> = {};
29+
30+
beforeEach(() => {
31+
saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]]));
32+
for (const k of TOUCHED) delete process.env[k];
33+
});
34+
35+
afterEach(() => {
36+
for (const k of TOUCHED) {
37+
if (saved[k] === undefined) delete process.env[k];
38+
else process.env[k] = saved[k];
39+
}
40+
});
41+
42+
/** The `baseUrl` a served boot's email capability is constructed with. */
43+
function emailBaseUrl(): unknown {
44+
const arg = resolveCapabilityArgument('email', {
45+
stack: {},
46+
packageRoot: '/app',
47+
providerModule: pluginEmail,
48+
env: process.env,
49+
}).argument as { defaultTemplateContext: Record<string, unknown> };
50+
return arg.defaultTemplateContext.baseUrl;
51+
}
52+
53+
/** What `serve` resolves for the same environment, on the port it would listen on. */
54+
function serveBaseUrl(): string {
55+
const port = process.env.OS_PORT ?? process.env.PORT ?? '3000';
56+
return resolveAuthBaseUrl(port).value.replace(/\/+$/, '');
57+
}
58+
59+
const ROWS: Array<[string, Partial<Record<(typeof TOUCHED)[number], string>>, string]> = [
60+
['nothing set: the local default', {}, 'http://localhost:3000'],
61+
['OS_PORT names the local default port', { OS_PORT: '4100' }, 'http://localhost:4100'],
62+
['legacy PORT names it when OS_PORT is unset', { PORT: '4200' }, 'http://localhost:4200'],
63+
['OS_PORT over PORT', { OS_PORT: '4100', PORT: '4200' }, 'http://localhost:4100'],
64+
['OS_BASE_URL over the default', { OS_BASE_URL: 'https://base.example.com/', OS_PORT: '4100' }, 'https://base.example.com'],
65+
['legacy BETTER_AUTH_URL over OS_BASE_URL',
66+
{ BETTER_AUTH_URL: 'https://legacy.example.com', OS_BASE_URL: 'https://base.example.com' }, 'https://legacy.example.com'],
67+
['OS_AUTH_URL over everything',
68+
{ OS_AUTH_URL: 'https://auth.example.com/', BETTER_AUTH_URL: 'https://legacy.example.com', OS_BASE_URL: 'https://base.example.com' },
69+
'https://auth.example.com'],
70+
['a set-but-empty OS_AUTH_URL is a value in both', { OS_AUTH_URL: '', OS_BASE_URL: 'https://base.example.com' }, ''],
71+
];
72+
73+
describe('email baseUrl ↔ serve resolveAuthBaseUrl — one deployment address', () => {
74+
it.each(ROWS)('%s', (_name, env, expected) => {
75+
Object.assign(process.env, env);
76+
expect(serveBaseUrl()).toBe(expected);
77+
expect(emailBaseUrl()).toBe(expected);
78+
});
79+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The template context's `baseUrl` key: the deployment's address, so a
5+
* template can render an absolute link to a record or an app route
6+
* (`{{baseUrl}}/<object>/<id>`).
7+
*
8+
* The chain, env first as for `appName`: `OS_AUTH_URL` (legacy
9+
* `BETTER_AUTH_URL`) > `OS_BASE_URL` > `config.email.defaultTemplateContext.baseUrl`
10+
* > `http://localhost:<port>`, never ending in a slash. The env half mirrors
11+
* `os serve`'s own reader; the parity pin against it lives in
12+
* `@objectstack/cli` (`serve-email-base-url-parity.test.ts`).
13+
*/
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { resolveEmailCapabilityArg } from './capability-arg.js';
17+
import { EmailService, type TemplateLoader, type EmailTemplateRow } from './email-service.js';
18+
import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts';
19+
20+
function contextOf(cfgEmail: Record<string, any>, env: NodeJS.ProcessEnv): Record<string, unknown> {
21+
return resolveEmailCapabilityArg(cfgEmail, env).options.defaultTemplateContext as Record<string, unknown>;
22+
}
23+
24+
const CONFIG = { defaultTemplateContext: { baseUrl: 'https://config.example.com' } };
25+
26+
describe('defaultTemplateContext.baseUrl — the deployment address chain', () => {
27+
it('OS_AUTH_URL wins over every other link', () => {
28+
expect(contextOf(CONFIG, {
29+
OS_AUTH_URL: 'https://auth.example.com',
30+
BETTER_AUTH_URL: 'https://legacy.example.com',
31+
OS_BASE_URL: 'https://base.example.com',
32+
OS_PORT: '4100',
33+
}).baseUrl).toBe('https://auth.example.com');
34+
});
35+
36+
it('the legacy BETTER_AUTH_URL stands in for OS_AUTH_URL, over OS_BASE_URL (serve reads it so)', () => {
37+
expect(contextOf(CONFIG, {
38+
BETTER_AUTH_URL: 'https://legacy.example.com',
39+
OS_BASE_URL: 'https://base.example.com',
40+
}).baseUrl).toBe('https://legacy.example.com');
41+
});
42+
43+
it('OS_BASE_URL wins over the config', () => {
44+
expect(contextOf(CONFIG, { OS_BASE_URL: 'https://base.example.com', OS_PORT: '4100' }).baseUrl)
45+
.toBe('https://base.example.com');
46+
});
47+
48+
it('the config wins over the local default', () => {
49+
expect(contextOf(CONFIG, { OS_PORT: '4100' }).baseUrl).toBe('https://config.example.com');
50+
});
51+
52+
it('with nothing set, the local default names the port serve listens on', () => {
53+
expect(contextOf({}, {}).baseUrl).toBe('http://localhost:3000');
54+
expect(contextOf({}, { PORT: '4200' }).baseUrl).toBe('http://localhost:4200');
55+
expect(contextOf({}, { OS_PORT: '4100', PORT: '4200' }).baseUrl).toBe('http://localhost:4100');
56+
});
57+
58+
it('a trailing slash is trimmed, from every link', () => {
59+
expect(contextOf({}, { OS_AUTH_URL: 'https://auth.example.com/' }).baseUrl).toBe('https://auth.example.com');
60+
expect(contextOf({}, { OS_BASE_URL: 'https://base.example.com/app//' }).baseUrl)
61+
.toBe('https://base.example.com/app');
62+
expect(contextOf({ defaultTemplateContext: { baseUrl: 'https://config.example.com/' } }, {}).baseUrl)
63+
.toBe('https://config.example.com');
64+
});
65+
66+
it('a set-but-empty OS_AUTH_URL is a value, as serve reads it — not skipped', () => {
67+
// `serve` resolves `OS_AUTH_URL=` to '' and warns at boot that the auth
68+
// base URL is unusable; the mirror takes the same answer rather than
69+
// inventing a second reading of the same variable.
70+
expect(contextOf(CONFIG, { OS_AUTH_URL: '', OS_BASE_URL: 'https://base.example.com' }).baseUrl).toBe('');
71+
});
72+
73+
it('control: appName resolves exactly as before, beside it', () => {
74+
expect(contextOf(
75+
{ appName: 'Key', defaultTemplateContext: { appName: 'Context', baseUrl: 'https://c.example.com' } },
76+
{ OS_APP_NAME: 'Env', OS_BASE_URL: 'https://base.example.com' },
77+
).appName).toBe('Env');
78+
expect(contextOf({ defaultTemplateContext: { appName: 'Context' } }, {}).appName).toBe('Context');
79+
expect(contextOf({}, {}).appName).toBe('ObjectStack');
80+
});
81+
});
82+
83+
// ── what a template renders with it ─────────────────────────────────────────
84+
85+
class CaptureTransport implements IEmailTransport {
86+
public sent: NormalizedEmailMessage[] = [];
87+
async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
88+
this.sent.push(message);
89+
return { messageId: `msg-${this.sent.length}` };
90+
}
91+
}
92+
93+
const recordLink: EmailTemplateRow = {
94+
name: 'crm.deal_assigned',
95+
locale: 'en-US',
96+
subject: 'Deal assigned',
97+
body_html: '<p>Open it at {{baseUrl}}/x/1</p>',
98+
body_text: 'Open it at {{baseUrl}}/x/1',
99+
active: true,
100+
};
101+
102+
function serviceWith(env: NodeJS.ProcessEnv): EmailService {
103+
const loader: TemplateLoader = { async load(name) { return name === recordLink.name ? recordLink : null; } };
104+
const { defaultTemplateContext } = resolveEmailCapabilityArg({}, env).options as {
105+
defaultTemplateContext: Record<string, unknown>;
106+
};
107+
return new EmailService({
108+
transport: new CaptureTransport(),
109+
defaultFrom: { address: 'no-reply@x.com' },
110+
templateLoader: loader,
111+
defaultTemplateContext,
112+
});
113+
}
114+
115+
describe('a template renders {{baseUrl}} as the absolute link', () => {
116+
it('produces the absolute link in every face', async () => {
117+
const out = await serviceWith({ OS_BASE_URL: 'https://crm.example.com/' })
118+
.renderTemplate({ template: 'crm.deal_assigned' });
119+
expect(out.text).toBe('Open it at https://crm.example.com/x/1');
120+
expect(out.html).toBe('<p>Open it at https://crm.example.com/x/1</p>');
121+
});
122+
123+
it("a send's own data.baseUrl still wins over the resolved default", async () => {
124+
const out = await serviceWith({ OS_BASE_URL: 'https://crm.example.com' })
125+
.renderTemplate({ template: 'crm.deal_assigned', data: { baseUrl: 'https://tenant.example.com' } });
126+
expect(out.text).toBe('Open it at https://tenant.example.com/x/1');
127+
});
128+
});

‎packages/plugins/plugin-email/src/capability-arg.config-parity.contract.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ describe('a config the schema accepts reaches the plugin intact', () => {
267267
}).options;
268268
expect(withEnv.defaultTemplateContext).toEqual({
269269
appName: 'From The Env',
270+
baseUrl: 'http://localhost:3000', // the deployment address, resolved beside appName
270271
supportEmail: 'help@acme.test', // every other context key still spreads verbatim
271272
});
272273
// The blast radius #5448 named: the placeholder sender is slugged from the

‎packages/plugins/plugin-email/src/capability-arg.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,42 @@ export function resolveDeploymentAppName(
8585
|| configAppName || 'ObjectStack';
8686
}
8787

88+
/**
89+
* The deployment's base URL for the template context's `baseUrl` key, so a
90+
* template can render an absolute link (`{{baseUrl}}/<object>/<id>`): the
91+
* chain `os serve` resolves this deployment's address with, plus the one
92+
* config carrier the template context already has, ending without a trailing
93+
* slash. Order: `OS_AUTH_URL` (legacy `BETTER_AUTH_URL`) > `OS_BASE_URL` >
94+
* `config.email.defaultTemplateContext.baseUrl` > `http://localhost:<port>`.
95+
* Env wins, as it does for `appName`.
96+
*
97+
* ⛔ A MIRROR, not a second authority. The source is `resolveAuthBaseUrl` in
98+
* `@objectstack/cli`'s `serve.ts`, which this package cannot import (the CLI
99+
* depends on it). The parity pin lives where both are reachable:
100+
* `serve-email-base-url-parity.test.ts` in `@objectstack/cli`, which drives
101+
* both readers over the same environment and goes red when either chain moves.
102+
* What is mirrored, and kept exactly as the source does it:
103+
* - `??`, not `||`: a SET-but-empty variable is a value and is not skipped,
104+
* as `readEnvWithDeprecation` and `serve` treat it (serve then warns about
105+
* it at boot; this reader adds no second dialect for it);
106+
* - the local tail's port is the one `serve`'s `--port` default reads
107+
* (`OS_PORT` > legacy `PORT` > `3000`), and a `--port` on argv reaches it
108+
* because `serve` exports it into the environment before the config loads.
109+
* Not mirrored, because no reader here can know it: the `https` tail of a
110+
* TLS listener and a dev port that shifted off a busy one. Both are the
111+
* unconfigured local default only; a deployment reached anywhere else sets
112+
* `OS_AUTH_URL` or `OS_BASE_URL`, which win.
113+
*/
114+
function resolveDeploymentBaseUrl(
115+
cfgEmail: Record<string, any>,
116+
env: NodeJS.ProcessEnv,
117+
): string {
118+
const port = env.OS_PORT ?? env.PORT ?? '3000';
119+
const value = env.OS_AUTH_URL ?? env.BETTER_AUTH_URL ?? env.OS_BASE_URL
120+
?? cfgEmail.defaultTemplateContext?.baseUrl ?? `http://localhost:${port}`;
121+
return String(value).replace(/\/+$/, '');
122+
}
123+
88124
/**
89125
* Resolve what `EmailServicePlugin` is constructed with, from `config.email`
90126
* plus `OS_EMAIL_*` env (env wins, so an operator can override per environment).
@@ -140,7 +176,9 @@ export function resolveDeploymentAppName(
140176
* spelled `defaultTemplateContext: { appName: … }` made `OS_APP_NAME` inert and
141177
* the per-environment override an operator has for a repo-pinned config did
142178
* nothing, silently, in the mail body AND in the `no-reply@<slug>.local`
143-
* fallback sender derived from it. Every OTHER key of `defaultTemplateContext`
179+
* fallback sender derived from it. `baseUrl` is the second key resolved the
180+
* same way: the deployment's address, env first (see
181+
* `resolveDeploymentBaseUrl`). Every OTHER key of `defaultTemplateContext`
144182
* is unchanged: it has no env or dedicated-config carrier, so the author's
145183
* context is still spread through wholesale.
146184
*/
@@ -198,9 +236,13 @@ export function resolveEmailCapabilityArg(
198236
// `config.email.appName` > `config.email.defaultTemplateContext.appName` >
199237
// top-level `config.appName` > 'ObjectStack'.
200238
const cfgTemplateContext = cfgEmail.defaultTemplateContext || {};
239+
// `baseUrl` is placed after the spread for the same reason, with the chain
240+
// {@link resolveDeploymentBaseUrl} documents. A send's own `data.baseUrl`
241+
// still wins: `EmailService` spreads the send's data over this context.
201242
const defaultTemplateContext = {
202243
...cfgTemplateContext,
203244
appName: resolveDeploymentAppName(cfgEmail, env, configAppName),
245+
baseUrl: resolveDeploymentBaseUrl(cfgEmail, env),
204246
};
205247
// Provide a sensible fallback `from` so templates can render even before
206248
// operators configure SMTP/SaaS. The log transport simply prints to stdout;

0 commit comments

Comments
 (0)