Skip to content

Commit 575448d

Browse files
os-zhuangclaude
andauthored
feat(formula,email): render datetime in reference timezone (ADR-0053 Phase 2) (#1981) (#2003)
* feat(formula,email): render datetime in reference timezone (ADR-0053 Phase 2, #1981) datetime template holes render in a reference timezone's wall-clock at the presentation boundary; storage stays UTC. Calendar-day `date` rendering is intentionally unaffected (tz-naive). - formula: the `datetime` formatter takes tz from EvalContext.timezone (#1980) → Intl.DateTimeFormat; `datetime:iso` stays UTC. New exported `formatValue(name, value, arg, { locale, timeZone })` so the whitelisted formatters are reusable outside the CEL template engine. - plugin-email: the renderer bypassed the formatter pipeline (raw String()), so a datetime went out as raw ISO. Email holes now accept the shared formula formatters via formatValue (single source of truth), keeping HTML-escaping and `{{{ }}}` raw-output. SendTemplateInput.timezone (mirroring locale) flows into rendering so a datetime shows the recipient's wall-clock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(email): linear placeholder matcher — close CodeQL polynomial-ReDoS The formatter-aware regex used two overlapping `\s*` groups (inside the optional `| formatter` group and trailing), which CodeQL flagged as js/polynomial-redos (slow on `{{{{.` + many tabs). Switch to a brace-free inner capture (`[^{}]*` — single star, no nested quantifier) and parse the hole body with plain string ops, the same linear strategy the formula template engine uses. Adds a regression test on the flagged input shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 601cc11 commit 575448d

12 files changed

Lines changed: 260 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/formula": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/plugin-email": minor
5+
---
6+
7+
feat(formula,email): render `datetime` in a reference timezone (ADR-0053 Phase 2)
8+
9+
`datetime` template holes now render in a reference timezone's wall-clock when one is supplied, at the presentation boundary — storage stays UTC.
10+
11+
- **Formula template engine** — the `datetime` formatter takes the reference timezone from `EvalContext.timezone` (threaded in #1980) and passes it to `Intl.DateTimeFormat`. `{{ ts | datetime }}` renders in that zone; `{{ ts | datetime:iso }}` stays UTC (machine-readable). Calendar-day `date` rendering is intentionally **unchanged** (tz-naive — a `Field.date` has no zone). New exported `formatValue(name, value, arg, { locale, timeZone })` makes the whitelisted formatters reusable outside the full CEL template engine.
12+
- **Email pipeline**`plugin-email`'s renderer previously bypassed the formatter pipeline (`String()` only), so a datetime went out as raw ISO. Email holes now accept the shared formula formatters — `{{ order.total | currency }}`, `{{ ts | datetime }}` — reusing `formatValue` (single source of truth), while keeping the engine's HTML-escaping and `{{{ }}}` raw-output semantics. `SendTemplateInput.timezone` (mirroring the existing `locale`) flows into rendering so an email's datetime shows the recipient's wall-clock.

packages/formula/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
export { ExpressionEngine, getEngine, hasDialect, register } from './registry';
1313
export { celEngine, DEFAULT_LIMITS } from './cel-engine';
1414
export { cronEngine } from './cron-engine';
15-
export { templateEngine, TEMPLATE_FORMATTERS } from './template-engine';
15+
export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine';
1616
export { registerStdLib, buildScope } from './stdlib';
1717
export { resolveSeed, resolveSeedRecord } from './seed-eval';
1818
export { normalizeExpression, normalizeExpressionTree } from './normalize';

packages/formula/src/template-engine.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ const HOLE_RE = /\{\{([^}]*)\}\}/g;
3232

3333
// ───────────────────────── formatter whitelist (ADR-0032 §3) ──────────────
3434

35-
type Formatter = (value: unknown, arg: string | undefined, locale: string) => string;
35+
type Formatter = (
36+
value: unknown,
37+
arg: string | undefined,
38+
locale: string,
39+
timeZone?: string,
40+
) => string;
3641

3742
function asNumber(v: unknown): number | undefined {
3843
if (typeof v === 'number') return v;
@@ -85,23 +90,28 @@ const FORMATTERS: Record<string, Formatter> = {
8590
maximumFractionDigits: Number.isNaN(digits) ? 0 : digits,
8691
}).format(n);
8792
},
88-
// date | date:long | date:iso → date-only
93+
// date | date:long | date:iso → date-only. Intentionally tz-naive
94+
// (ADR-0053): a `Field.date` is a calendar day with no zone, so rendering
95+
// never applies a reference timezone — that would shift the day.
8996
date: (v, arg, locale) => {
9097
const d = asDate(v);
9198
if (!d) return baseString(v);
9299
if (arg === 'iso') return d.toISOString().slice(0, 10);
93100
const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
94101
return new Intl.DateTimeFormat(locale, { dateStyle: style as 'short' | 'medium' | 'long' }).format(d);
95102
},
96-
// datetime | datetime:long | datetime:iso
97-
datetime: (v, arg, locale) => {
103+
// datetime | datetime:long | datetime:iso. A `datetime` is a UTC instant;
104+
// when a reference `timeZone` is supplied (ADR-0053 Phase 2) the wall-clock
105+
// styles render in that zone. `iso` stays UTC (machine-readable, unambiguous).
106+
datetime: (v, arg, locale, timeZone) => {
98107
const d = asDate(v);
99108
if (!d) return baseString(v);
100109
if (arg === 'iso') return d.toISOString();
101110
const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
102111
return new Intl.DateTimeFormat(locale, {
103112
dateStyle: style as 'short' | 'medium' | 'long',
104113
timeStyle: style as 'short' | 'medium' | 'long',
114+
...(timeZone ? { timeZone } : {}),
105115
}).format(d);
106116
},
107117
// truncate:80 → cut with an ellipsis
@@ -124,6 +134,27 @@ const FORMATTERS: Record<string, Formatter> = {
124134
/** Public list of whitelisted template formatters (for introspection/docs). */
125135
export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS);
126136

137+
/**
138+
* Apply a whitelisted formatter to a value, the single source of truth for
139+
* value→string semantics across dialects. Returns `undefined` for an unknown
140+
* formatter name so callers can decide how to handle it (the template engine
141+
* rejects at compile time; other consumers may pass the raw value through).
142+
*
143+
* Exported so renderers that don't run the full CEL template engine — notably
144+
* the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc.
145+
* identically to in-app templates, including reference-timezone `datetime`.
146+
*/
147+
export function formatValue(
148+
name: string,
149+
value: unknown,
150+
arg: string | undefined,
151+
opts: { locale?: string; timeZone?: string } = {},
152+
): string | undefined {
153+
const fmt = FORMATTERS[name];
154+
if (!fmt) return undefined;
155+
return fmt(value, arg, opts.locale ?? 'en-US', opts.timeZone);
156+
}
157+
127158
function baseString(value: unknown): string {
128159
if (value === null || value === undefined) return '';
129160
if (value instanceof Date) return value.toISOString();
@@ -234,13 +265,16 @@ export const templateEngine: DialectEngine = {
234265
(ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) ||
235266
(typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) ||
236267
'en-US';
268+
// Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset →
269+
// Intl uses the runtime zone, matching pre-Phase-2 behavior.
270+
const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined;
237271

238272
const out = expr.source.replace(HOLE_RE, (_match, inner) => {
239273
const parsed = parseHole(String(inner));
240274
if (!parsed) return _match; // compile already validated; defensive
241275
const value = resolvePath(scope, parsed.path);
242276
if (parsed.filter) {
243-
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string);
277+
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone);
244278
}
245279
return baseString(value);
246280
});

packages/formula/src/template-formatters.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, it, expect } from 'vitest';
2-
import { templateEngine, TEMPLATE_FORMATTERS } from './template-engine';
2+
import { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine';
33

4-
function render(source: string, record: Record<string, unknown>): string {
5-
const r = templateEngine.evaluate<string>({ dialect: 'template', source }, { record, extra: { locale: 'en-US' } });
4+
function render(source: string, record: Record<string, unknown>, timezone?: string): string {
5+
const r = templateEngine.evaluate<string>(
6+
{ dialect: 'template', source },
7+
{ record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) },
8+
);
69
if (!r.ok) throw new Error(r.error.message);
710
return r.value;
811
}
@@ -30,6 +33,36 @@ describe('template formatters (ADR-0032 §3)', () => {
3033
expect(render('{{ record.d | date:iso }}', { d: '2026-06-02T10:00:00Z' })).toBe('2026-06-02');
3134
});
3235

36+
// ADR-0053 Phase 2: datetime renders in the reference timezone.
37+
describe('datetime in a reference timezone', () => {
38+
// 2026-06-02T01:30Z is 2026-06-01 21:30 in America/New_York (EDT, -4).
39+
const ts = '2026-06-02T01:30:00Z';
40+
41+
it('renders the wall-clock of the reference zone for styled output', () => {
42+
const ny = render('{{ record.t | datetime }}', { t: ts }, 'America/New_York');
43+
expect(ny).toContain('6/1/26'); // shifted back a day in NY
44+
const utc = render('{{ record.t | datetime }}', { t: ts }, 'UTC');
45+
expect(utc).toContain('6/2/26'); // same instant, UTC calendar day
46+
});
47+
48+
it('keeps `iso` machine-readable (always UTC) regardless of zone', () => {
49+
expect(render('{{ record.t | datetime:iso }}', { t: ts }, 'America/New_York'))
50+
.toBe('2026-06-02T01:30:00.000Z');
51+
});
52+
53+
it('leaves calendar-day `date` tz-naive (criterion 3)', () => {
54+
// date:iso is UTC-sliced and must not move under a negative-offset zone.
55+
expect(render('{{ record.d | date:iso }}', { d: '2026-06-02' }, 'America/New_York'))
56+
.toBe('2026-06-02');
57+
});
58+
59+
it('formatValue is reusable with an explicit timeZone (email pipeline path)', () => {
60+
const out = formatValue('datetime', ts, undefined, { locale: 'en-US', timeZone: 'UTC' });
61+
expect(out).toContain('6/2/26');
62+
expect(formatValue('bogus', ts, undefined, {})).toBeUndefined();
63+
});
64+
});
65+
3366
it('truncate + upper + default', () => {
3467
expect(render('{{ record.s | truncate:5 }}', { s: 'abcdefgh' })).toBe('abcd…');
3568
expect(render('{{ record.s | upper }}', { s: 'hi' })).toBe('HI');

packages/formula/src/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ export interface EvalContext {
2525
now?: Date;
2626
/**
2727
* Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day
28-
* functions `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2).
29-
* Defaults to `UTC` when unset.
28+
* functions `today()` / `daysFromNow()` / `daysAgo()` and for rendering
29+
* `datetime` template holes in that zone's wall-clock (ADR-0053 Phase 2).
30+
* Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
3031
*/
3132
timezone?: string;
3233
/** Current authenticated subject (hook / action / view contexts). */

packages/plugins/plugin-email/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"dependencies": {
2020
"@objectstack/core": "workspace:*",
21+
"@objectstack/formula": "workspace:*",
2122
"@objectstack/platform-objects": "workspace:*",
2223
"@objectstack/spec": "workspace:*"
2324
},

packages/plugins/plugin-email/src/email-service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,16 @@ export class EmailService implements IEmailService {
333333
}
334334
}
335335

336-
const subject = renderTemplate(row.subject, data);
337-
const html = renderTemplate(row.body_html, data);
336+
// Render holes with the recipient's locale + reference timezone so
337+
// `{{ ts | datetime }}` shows the right wall-clock (ADR-0053 Phase 2).
338+
const renderOpts = {
339+
...(input.locale ? { locale: input.locale } : {}),
340+
...(input.timezone ? { timeZone: input.timezone } : {}),
341+
};
342+
const subject = renderTemplate(row.subject, data, renderOpts);
343+
const html = renderTemplate(row.body_html, data, renderOpts);
338344
const text = row.body_text
339-
? renderTemplate(row.body_text, data)
345+
? renderTemplate(row.body_text, data, renderOpts)
340346
: htmlToText(html);
341347

342348
const from: EmailAddress | undefined = input.from

packages/plugins/plugin-email/src/send-template.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ describe('EmailService.sendTemplate', () => {
6060
expect(msg.text).toContain('reset: https://x.com/r/abc');
6161
});
6262

63+
it('renders a datetime hole in the input reference timezone (ADR-0053 Phase 2)', async () => {
64+
const transport = new CaptureTransport();
65+
const tpl: EmailTemplateRow = {
66+
name: 'order.shipped',
67+
locale: 'en-US',
68+
subject: 'Shipped',
69+
body_html: '<p>Ships {{ shipAt | datetime }}</p>',
70+
body_text: 'Ships {{ shipAt | datetime }}',
71+
active: true,
72+
};
73+
const svc = new EmailService({
74+
transport,
75+
defaultFrom: { address: 'no-reply@x.com' },
76+
templateLoader: makeLoader([tpl]),
77+
});
78+
79+
// 2026-06-02T01:30Z → 2026-06-01 in America/New_York.
80+
await svc.sendTemplate({
81+
template: 'order.shipped',
82+
to: 'a@x.com',
83+
data: { shipAt: '2026-06-02T01:30:00Z' },
84+
timezone: 'America/New_York',
85+
});
86+
87+
expect(transport.sent[0].html).toContain('6/1/26'); // shifted to NY day
88+
expect(transport.sent[0].html).not.toContain('2026-06-02T01:30'); // not raw ISO
89+
});
90+
6391
it('throws TEMPLATE_NOT_FOUND when loader returns null', async () => {
6492
const svc = new EmailService({
6593
transport: new CaptureTransport(),

packages/plugins/plugin-email/src/template-engine.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,49 @@ describe('template-engine', () => {
3737
it('escapes all standard HTML entities', () => {
3838
expect(renderTemplate('{{s}}', { s: `&<>"'` })).toBe('&amp;&lt;&gt;&quot;&#39;');
3939
});
40+
41+
// ADR-0053 Phase 2: formatter holes reuse the shared formula whitelist.
42+
describe('formatter holes', () => {
43+
it('applies currency / number formatters', () => {
44+
expect(renderTemplate('{{ amt | currency }}', { amt: 1234.5 })).toBe('$1,234.50');
45+
expect(renderTemplate('{{ n | number:2 }}', { n: 1000 })).toBe('1,000.00');
46+
});
47+
48+
it('renders datetime in the supplied reference timezone', () => {
49+
// 2026-06-02T01:30Z → 2026-06-01 in America/New_York.
50+
const data = { ts: '2026-06-02T01:30:00Z' };
51+
const ny = renderTemplate('{{ ts | datetime }}', data, { timeZone: 'America/New_York' });
52+
expect(ny).toContain('6/1/26');
53+
const utc = renderTemplate('{{ ts | datetime }}', data, { timeZone: 'UTC' });
54+
expect(utc).toContain('6/2/26');
55+
});
56+
57+
it('still HTML-escapes formatted output unless triple-braced', () => {
58+
// A formatter can yield characters needing escaping; default escapes.
59+
expect(renderTemplate('{{ s | upper }}', { s: 'a&b' })).toBe('A&amp;B');
60+
expect(renderTemplate('{{{ s | upper }}}', { s: 'a&b' })).toBe('A&B');
61+
});
62+
63+
it('falls back to the raw value for an unknown formatter (no throw)', () => {
64+
expect(renderTemplate('{{ x | bogus }}', { x: 'hi' })).toBe('hi');
65+
});
66+
67+
it('renders a missing formatted value as empty (never "undefined")', () => {
68+
expect(renderTemplate('{{ missing | datetime }}', {})).toBe('');
69+
});
70+
71+
// Regression: the placeholder matcher must stay linear. CodeQL flagged a
72+
// polynomial-ReDoS on inputs like `{{{{.` + many tabs; the brace-free
73+
// `[^{}]*` capture removes the backtracking. Pathological input resolves
74+
// fast and is left verbatim (not a valid path[+formatter] hole).
75+
it('handles pathological brace/whitespace input without backtracking', () => {
76+
const evil = '{{{{.' + '\t'.repeat(50_000);
77+
const start = Date.now();
78+
const out = renderTemplate(evil + '}}', {});
79+
expect(Date.now() - start).toBeLessThan(1000);
80+
expect(typeof out).toBe('string');
81+
});
82+
});
4083
});
4184

4285
describe('requireVars', () => {

packages/plugins/plugin-email/src/template-engine.ts

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,60 @@
99
* (e.g. when injecting pre-rendered HTML fragments such as URLs in
1010
* `<a href="">`).
1111
*
12+
* A hole may carry an optional formatter from the shared formula
13+
* whitelist — `{{ order.total | currency:EUR }}`, `{{ ts | datetime }}` —
14+
* reusing `@objectstack/formula`'s `formatValue` so dates, money, and
15+
* (ADR-0053 Phase 2) reference-timezone `datetime` render identically to
16+
* in-app templates. An unknown formatter falls back to the raw string,
17+
* keeping the lenient "never throw on render" contract.
18+
*
1219
* Deliberately tiny (no loops / conditionals / partials) — the design
1320
* stance is that email templates SHOULD be data-only renderings; any
1421
* branching belongs in the caller. If we ever need more, swap for
1522
* Handlebars, but bringing it in costs ~50KB and pulls a parser at
1623
* runtime; we resist that until a real use case demands it.
1724
*/
1825

19-
const PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g;
26+
import { formatValue } from '@objectstack/formula';
27+
28+
// Match a hole: open braces, a BRACE-FREE inner body, close braces. Capturing
29+
// the inner with `[^{}]*` (a single star over a negated class — no nested
30+
// quantifier, no overlapping `\s*` groups) keeps the matcher strictly linear:
31+
// it can never backtrack into a polynomial-ReDoS shape. The inner body is then
32+
// parsed with plain string ops (`parseHole`) instead of a complex pattern.
33+
// 1=open(`{{`|`{{{`) 2=inner 3=close(`}}`|`}}}`).
34+
const PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g;
35+
36+
/** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */
37+
export interface RenderOptions {
38+
locale?: string;
39+
timeZone?: string;
40+
}
41+
42+
// A hole body is a dotted path with an optional `| formatter[:arg]`. Validated
43+
// against small, already-extracted strings (bounded input → no ReDoS).
44+
const PATH_RE = /^[\w.]+$/;
45+
const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/;
46+
47+
interface ParsedHole {
48+
path: string;
49+
formatter?: string;
50+
arg?: string;
51+
}
52+
53+
/** Parse a hole's inner body into a path + optional formatter. Null if malformed. */
54+
function parseHole(inner: string): ParsedHole | null {
55+
const pipe = inner.indexOf('|');
56+
if (pipe === -1) {
57+
const path = inner.trim();
58+
return PATH_RE.test(path) ? { path } : null;
59+
}
60+
const path = inner.slice(0, pipe).trim();
61+
if (!PATH_RE.test(path)) return null;
62+
const m = FILTER_RE.exec(inner.slice(pipe + 1).trim());
63+
if (!m) return null;
64+
return { path, formatter: m[1], arg: m[2] };
65+
}
2066

2167
function lookup(data: Record<string, any>, path: string): unknown {
2268
if (!path) return undefined;
@@ -43,15 +89,37 @@ function escapeHtml(s: string): string {
4389
* render as empty strings (no throw); call `requireVars()` first if
4490
* you need strict validation.
4591
*/
46-
export function renderTemplate(template: string, data: Record<string, any>): string {
92+
export function renderTemplate(
93+
template: string,
94+
data: Record<string, any>,
95+
opts: RenderOptions = {},
96+
): string {
4797
if (!template) return '';
48-
return template.replace(PLACEHOLDER, (_match, open: string, path: string, close: string) => {
49-
const isUnescaped = open === '{{{' && close === '}}}';
50-
const raw = lookup(data, path);
51-
if (raw == null) return '';
52-
const str = typeof raw === 'string' ? raw : String(raw);
53-
return isUnescaped ? str : escapeHtml(str);
54-
});
98+
return template.replace(
99+
PLACEHOLDER,
100+
(match: string, open: string, inner: string, close: string) => {
101+
const parsed = parseHole(inner);
102+
if (!parsed) return match; // not a path[+formatter] hole — leave verbatim
103+
const isUnescaped = open === '{{{' && close === '}}}';
104+
const raw = lookup(data, parsed.path);
105+
let str: string;
106+
if (parsed.formatter) {
107+
// Formatted holes render '' for a missing value (the formula formatters
108+
// treat null as empty), so they never emit "undefined".
109+
const formatted = formatValue(parsed.formatter, raw, parsed.arg, {
110+
locale: opts.locale,
111+
timeZone: opts.timeZone,
112+
});
113+
str = formatted !== undefined
114+
? formatted
115+
: raw == null ? '' : (typeof raw === 'string' ? raw : String(raw)); // unknown formatter → raw
116+
} else {
117+
if (raw == null) return '';
118+
str = typeof raw === 'string' ? raw : String(raw);
119+
}
120+
return isUnescaped ? str : escapeHtml(str);
121+
},
122+
);
55123
}
56124

57125
/**

0 commit comments

Comments
 (0)