diff --git a/.changeset/tz-datetime-rendering.md b/.changeset/tz-datetime-rendering.md new file mode 100644 index 0000000000..431b3d1199 --- /dev/null +++ b/.changeset/tz-datetime-rendering.md @@ -0,0 +1,12 @@ +--- +"@objectstack/formula": minor +"@objectstack/spec": minor +"@objectstack/plugin-email": minor +--- + +feat(formula,email): render `datetime` in a reference timezone (ADR-0053 Phase 2) + +`datetime` template holes now render in a reference timezone's wall-clock when one is supplied, at the presentation boundary — storage stays UTC. + +- **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. +- **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. diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 4ddfa9bdc4..219bb106c5 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -12,7 +12,7 @@ export { ExpressionEngine, getEngine, hasDialect, register } from './registry'; export { celEngine, DEFAULT_LIMITS } from './cel-engine'; export { cronEngine } from './cron-engine'; -export { templateEngine, TEMPLATE_FORMATTERS } from './template-engine'; +export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine'; export { registerStdLib, buildScope } from './stdlib'; export { resolveSeed, resolveSeedRecord } from './seed-eval'; export { normalizeExpression, normalizeExpressionTree } from './normalize'; diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index 226c086d50..6df42dddc5 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -32,7 +32,12 @@ const HOLE_RE = /\{\{([^}]*)\}\}/g; // ───────────────────────── formatter whitelist (ADR-0032 §3) ────────────── -type Formatter = (value: unknown, arg: string | undefined, locale: string) => string; +type Formatter = ( + value: unknown, + arg: string | undefined, + locale: string, + timeZone?: string, +) => string; function asNumber(v: unknown): number | undefined { if (typeof v === 'number') return v; @@ -85,7 +90,9 @@ const FORMATTERS: Record = { maximumFractionDigits: Number.isNaN(digits) ? 0 : digits, }).format(n); }, - // date | date:long | date:iso → date-only + // date | date:long | date:iso → date-only. Intentionally tz-naive + // (ADR-0053): a `Field.date` is a calendar day with no zone, so rendering + // never applies a reference timezone — that would shift the day. date: (v, arg, locale) => { const d = asDate(v); if (!d) return baseString(v); @@ -93,8 +100,10 @@ const FORMATTERS: Record = { const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short'; return new Intl.DateTimeFormat(locale, { dateStyle: style as 'short' | 'medium' | 'long' }).format(d); }, - // datetime | datetime:long | datetime:iso - datetime: (v, arg, locale) => { + // datetime | datetime:long | datetime:iso. A `datetime` is a UTC instant; + // when a reference `timeZone` is supplied (ADR-0053 Phase 2) the wall-clock + // styles render in that zone. `iso` stays UTC (machine-readable, unambiguous). + datetime: (v, arg, locale, timeZone) => { const d = asDate(v); if (!d) return baseString(v); if (arg === 'iso') return d.toISOString(); @@ -102,6 +111,7 @@ const FORMATTERS: Record = { return new Intl.DateTimeFormat(locale, { dateStyle: style as 'short' | 'medium' | 'long', timeStyle: style as 'short' | 'medium' | 'long', + ...(timeZone ? { timeZone } : {}), }).format(d); }, // truncate:80 → cut with an ellipsis @@ -124,6 +134,27 @@ const FORMATTERS: Record = { /** Public list of whitelisted template formatters (for introspection/docs). */ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); +/** + * Apply a whitelisted formatter to a value, the single source of truth for + * value→string semantics across dialects. Returns `undefined` for an unknown + * formatter name so callers can decide how to handle it (the template engine + * rejects at compile time; other consumers may pass the raw value through). + * + * Exported so renderers that don't run the full CEL template engine — notably + * the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc. + * identically to in-app templates, including reference-timezone `datetime`. + */ +export function formatValue( + name: string, + value: unknown, + arg: string | undefined, + opts: { locale?: string; timeZone?: string } = {}, +): string | undefined { + const fmt = FORMATTERS[name]; + if (!fmt) return undefined; + return fmt(value, arg, opts.locale ?? 'en-US', opts.timeZone); +} + function baseString(value: unknown): string { if (value === null || value === undefined) return ''; if (value instanceof Date) return value.toISOString(); @@ -234,13 +265,16 @@ export const templateEngine: DialectEngine = { (ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) || (typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) || 'en-US'; + // Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset → + // Intl uses the runtime zone, matching pre-Phase-2 behavior. + const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined; const out = expr.source.replace(HOLE_RE, (_match, inner) => { const parsed = parseHole(String(inner)); if (!parsed) return _match; // compile already validated; defensive const value = resolvePath(scope, parsed.path); if (parsed.filter) { - return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string); + return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone); } return baseString(value); }); diff --git a/packages/formula/src/template-formatters.test.ts b/packages/formula/src/template-formatters.test.ts index e7d975a7b2..6d18d9669f 100644 --- a/packages/formula/src/template-formatters.test.ts +++ b/packages/formula/src/template-formatters.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { templateEngine, TEMPLATE_FORMATTERS } from './template-engine'; +import { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine'; -function render(source: string, record: Record): string { - const r = templateEngine.evaluate({ dialect: 'template', source }, { record, extra: { locale: 'en-US' } }); +function render(source: string, record: Record, timezone?: string): string { + const r = templateEngine.evaluate( + { dialect: 'template', source }, + { record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) }, + ); if (!r.ok) throw new Error(r.error.message); return r.value; } @@ -30,6 +33,36 @@ describe('template formatters (ADR-0032 §3)', () => { expect(render('{{ record.d | date:iso }}', { d: '2026-06-02T10:00:00Z' })).toBe('2026-06-02'); }); + // ADR-0053 Phase 2: datetime renders in the reference timezone. + describe('datetime in a reference timezone', () => { + // 2026-06-02T01:30Z is 2026-06-01 21:30 in America/New_York (EDT, -4). + const ts = '2026-06-02T01:30:00Z'; + + it('renders the wall-clock of the reference zone for styled output', () => { + const ny = render('{{ record.t | datetime }}', { t: ts }, 'America/New_York'); + expect(ny).toContain('6/1/26'); // shifted back a day in NY + const utc = render('{{ record.t | datetime }}', { t: ts }, 'UTC'); + expect(utc).toContain('6/2/26'); // same instant, UTC calendar day + }); + + it('keeps `iso` machine-readable (always UTC) regardless of zone', () => { + expect(render('{{ record.t | datetime:iso }}', { t: ts }, 'America/New_York')) + .toBe('2026-06-02T01:30:00.000Z'); + }); + + it('leaves calendar-day `date` tz-naive (criterion 3)', () => { + // date:iso is UTC-sliced and must not move under a negative-offset zone. + expect(render('{{ record.d | date:iso }}', { d: '2026-06-02' }, 'America/New_York')) + .toBe('2026-06-02'); + }); + + it('formatValue is reusable with an explicit timeZone (email pipeline path)', () => { + const out = formatValue('datetime', ts, undefined, { locale: 'en-US', timeZone: 'UTC' }); + expect(out).toContain('6/2/26'); + expect(formatValue('bogus', ts, undefined, {})).toBeUndefined(); + }); + }); + it('truncate + upper + default', () => { expect(render('{{ record.s | truncate:5 }}', { s: 'abcdefgh' })).toBe('abcd…'); expect(render('{{ record.s | upper }}', { s: 'hi' })).toBe('HI'); diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index b8c36e4b96..d743f972c0 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -25,8 +25,9 @@ export interface EvalContext { now?: Date; /** * Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day - * functions `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2). - * Defaults to `UTC` when unset. + * functions `today()` / `daysFromNow()` / `daysAgo()` and for rendering + * `datetime` template holes in that zone's wall-clock (ADR-0053 Phase 2). + * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive. */ timezone?: string; /** Current authenticated subject (hook / action / view contexts). */ diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index faa1feabfa..7457bdb782 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/formula": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*" }, diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 6504856c5e..b12352bbb6 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -333,10 +333,16 @@ export class EmailService implements IEmailService { } } - const subject = renderTemplate(row.subject, data); - const html = renderTemplate(row.body_html, data); + // Render holes with the recipient's locale + reference timezone so + // `{{ ts | datetime }}` shows the right wall-clock (ADR-0053 Phase 2). + const renderOpts = { + ...(input.locale ? { locale: input.locale } : {}), + ...(input.timezone ? { timeZone: input.timezone } : {}), + }; + const subject = renderTemplate(row.subject, data, renderOpts); + const html = renderTemplate(row.body_html, data, renderOpts); const text = row.body_text - ? renderTemplate(row.body_text, data) + ? renderTemplate(row.body_text, data, renderOpts) : htmlToText(html); const from: EmailAddress | undefined = input.from diff --git a/packages/plugins/plugin-email/src/send-template.test.ts b/packages/plugins/plugin-email/src/send-template.test.ts index 76e9c8be43..03e50dfdc9 100644 --- a/packages/plugins/plugin-email/src/send-template.test.ts +++ b/packages/plugins/plugin-email/src/send-template.test.ts @@ -60,6 +60,34 @@ describe('EmailService.sendTemplate', () => { expect(msg.text).toContain('reset: https://x.com/r/abc'); }); + it('renders a datetime hole in the input reference timezone (ADR-0053 Phase 2)', async () => { + const transport = new CaptureTransport(); + const tpl: EmailTemplateRow = { + name: 'order.shipped', + locale: 'en-US', + subject: 'Shipped', + body_html: '

Ships {{ shipAt | datetime }}

', + body_text: 'Ships {{ shipAt | datetime }}', + active: true, + }; + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader: makeLoader([tpl]), + }); + + // 2026-06-02T01:30Z → 2026-06-01 in America/New_York. + await svc.sendTemplate({ + template: 'order.shipped', + to: 'a@x.com', + data: { shipAt: '2026-06-02T01:30:00Z' }, + timezone: 'America/New_York', + }); + + expect(transport.sent[0].html).toContain('6/1/26'); // shifted to NY day + expect(transport.sent[0].html).not.toContain('2026-06-02T01:30'); // not raw ISO + }); + it('throws TEMPLATE_NOT_FOUND when loader returns null', async () => { const svc = new EmailService({ transport: new CaptureTransport(), diff --git a/packages/plugins/plugin-email/src/template-engine.test.ts b/packages/plugins/plugin-email/src/template-engine.test.ts index 645fabc36c..377a198f2a 100644 --- a/packages/plugins/plugin-email/src/template-engine.test.ts +++ b/packages/plugins/plugin-email/src/template-engine.test.ts @@ -37,6 +37,49 @@ describe('template-engine', () => { it('escapes all standard HTML entities', () => { expect(renderTemplate('{{s}}', { s: `&<>"'` })).toBe('&<>"''); }); + + // ADR-0053 Phase 2: formatter holes reuse the shared formula whitelist. + describe('formatter holes', () => { + it('applies currency / number formatters', () => { + expect(renderTemplate('{{ amt | currency }}', { amt: 1234.5 })).toBe('$1,234.50'); + expect(renderTemplate('{{ n | number:2 }}', { n: 1000 })).toBe('1,000.00'); + }); + + it('renders datetime in the supplied reference timezone', () => { + // 2026-06-02T01:30Z → 2026-06-01 in America/New_York. + const data = { ts: '2026-06-02T01:30:00Z' }; + const ny = renderTemplate('{{ ts | datetime }}', data, { timeZone: 'America/New_York' }); + expect(ny).toContain('6/1/26'); + const utc = renderTemplate('{{ ts | datetime }}', data, { timeZone: 'UTC' }); + expect(utc).toContain('6/2/26'); + }); + + it('still HTML-escapes formatted output unless triple-braced', () => { + // A formatter can yield characters needing escaping; default escapes. + expect(renderTemplate('{{ s | upper }}', { s: 'a&b' })).toBe('A&B'); + expect(renderTemplate('{{{ s | upper }}}', { s: 'a&b' })).toBe('A&B'); + }); + + it('falls back to the raw value for an unknown formatter (no throw)', () => { + expect(renderTemplate('{{ x | bogus }}', { x: 'hi' })).toBe('hi'); + }); + + it('renders a missing formatted value as empty (never "undefined")', () => { + expect(renderTemplate('{{ missing | datetime }}', {})).toBe(''); + }); + + // Regression: the placeholder matcher must stay linear. CodeQL flagged a + // polynomial-ReDoS on inputs like `{{{{.` + many tabs; the brace-free + // `[^{}]*` capture removes the backtracking. Pathological input resolves + // fast and is left verbatim (not a valid path[+formatter] hole). + it('handles pathological brace/whitespace input without backtracking', () => { + const evil = '{{{{.' + '\t'.repeat(50_000); + const start = Date.now(); + const out = renderTemplate(evil + '}}', {}); + expect(Date.now() - start).toBeLessThan(1000); + expect(typeof out).toBe('string'); + }); + }); }); describe('requireVars', () => { diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index 1247818151..40b7cd1f93 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -9,6 +9,13 @@ * (e.g. when injecting pre-rendered HTML fragments such as URLs in * ``). * + * A hole may carry an optional formatter from the shared formula + * whitelist — `{{ order.total | currency:EUR }}`, `{{ ts | datetime }}` — + * reusing `@objectstack/formula`'s `formatValue` so dates, money, and + * (ADR-0053 Phase 2) reference-timezone `datetime` render identically to + * in-app templates. An unknown formatter falls back to the raw string, + * keeping the lenient "never throw on render" contract. + * * Deliberately tiny (no loops / conditionals / partials) — the design * stance is that email templates SHOULD be data-only renderings; any * branching belongs in the caller. If we ever need more, swap for @@ -16,7 +23,46 @@ * runtime; we resist that until a real use case demands it. */ -const PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g; +import { formatValue } from '@objectstack/formula'; + +// Match a hole: open braces, a BRACE-FREE inner body, close braces. Capturing +// the inner with `[^{}]*` (a single star over a negated class — no nested +// quantifier, no overlapping `\s*` groups) keeps the matcher strictly linear: +// it can never backtrack into a polynomial-ReDoS shape. The inner body is then +// parsed with plain string ops (`parseHole`) instead of a complex pattern. +// 1=open(`{{`|`{{{`) 2=inner 3=close(`}}`|`}}}`). +const PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g; + +/** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */ +export interface RenderOptions { + locale?: string; + timeZone?: string; +} + +// A hole body is a dotted path with an optional `| formatter[:arg]`. Validated +// against small, already-extracted strings (bounded input → no ReDoS). +const PATH_RE = /^[\w.]+$/; +const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; + +interface ParsedHole { + path: string; + formatter?: string; + arg?: string; +} + +/** Parse a hole's inner body into a path + optional formatter. Null if malformed. */ +function parseHole(inner: string): ParsedHole | null { + const pipe = inner.indexOf('|'); + if (pipe === -1) { + const path = inner.trim(); + return PATH_RE.test(path) ? { path } : null; + } + const path = inner.slice(0, pipe).trim(); + if (!PATH_RE.test(path)) return null; + const m = FILTER_RE.exec(inner.slice(pipe + 1).trim()); + if (!m) return null; + return { path, formatter: m[1], arg: m[2] }; +} function lookup(data: Record, path: string): unknown { if (!path) return undefined; @@ -43,15 +89,37 @@ function escapeHtml(s: string): string { * render as empty strings (no throw); call `requireVars()` first if * you need strict validation. */ -export function renderTemplate(template: string, data: Record): string { +export function renderTemplate( + template: string, + data: Record, + opts: RenderOptions = {}, +): string { if (!template) return ''; - return template.replace(PLACEHOLDER, (_match, open: string, path: string, close: string) => { - const isUnescaped = open === '{{{' && close === '}}}'; - const raw = lookup(data, path); - if (raw == null) return ''; - const str = typeof raw === 'string' ? raw : String(raw); - return isUnescaped ? str : escapeHtml(str); - }); + return template.replace( + PLACEHOLDER, + (match: string, open: string, inner: string, close: string) => { + const parsed = parseHole(inner); + if (!parsed) return match; // not a path[+formatter] hole — leave verbatim + const isUnescaped = open === '{{{' && close === '}}}'; + const raw = lookup(data, parsed.path); + let str: string; + if (parsed.formatter) { + // Formatted holes render '' for a missing value (the formula formatters + // treat null as empty), so they never emit "undefined". + const formatted = formatValue(parsed.formatter, raw, parsed.arg, { + locale: opts.locale, + timeZone: opts.timeZone, + }); + str = formatted !== undefined + ? formatted + : raw == null ? '' : (typeof raw === 'string' ? raw : String(raw)); // unknown formatter → raw + } else { + if (raw == null) return ''; + str = typeof raw === 'string' ? raw : String(raw); + } + return isUnescaped ? str : escapeHtml(str); + }, + ); } /** diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 926e94ca24..46a318edae 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -142,6 +142,14 @@ export interface SendTemplateInput { data?: Record; /** Preferred BCP-47 locale (e.g. user's locale). Falls back to `'en-US'`. */ locale?: string; + /** + * Reference timezone (IANA name, e.g. `America/New_York`) for rendering + * `datetime` holes — `{{ ts | datetime }}` (ADR-0053 Phase 2). The caller + * supplies the recipient's / tenant's zone (typically from the resolved + * `ExecutionContext.timezone`). Unset → the runtime zone (pre-Phase-2 + * behavior). Calendar-day `date` holes are unaffected (tz-naive). + */ + timezone?: string; /** Tenant id for org-overlay resolution (when supported). */ org?: string; /** Envelope sender override (otherwise template.fromOverride → service default). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2f2c18682..46e3df8676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1353,6 +1353,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/formula': + specifier: workspace:* + version: link:../../formula '@objectstack/platform-objects': specifier: workspace:* version: link:../../platform-objects