Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/tz-datetime-rendering.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
44 changes: 39 additions & 5 deletions packages/formula/src/template-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -85,23 +90,28 @@ const FORMATTERS: Record<string, Formatter> = {
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);
if (arg === 'iso') return d.toISOString().slice(0, 10);
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();
const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
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
Expand All @@ -124,6 +134,27 @@ const FORMATTERS: Record<string, Formatter> = {
/** 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();
Expand Down Expand Up @@ -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);
});
Expand Down
39 changes: 36 additions & 3 deletions packages/formula/src/template-formatters.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
const r = templateEngine.evaluate<string>({ dialect: 'template', source }, { record, extra: { locale: 'en-US' } });
function render(source: string, record: Record<string, unknown>, timezone?: string): string {
const r = templateEngine.evaluate<string>(
{ dialect: 'template', source },
{ record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) },
);
if (!r.ok) throw new Error(r.error.message);
return r.value;
}
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions packages/formula/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/spec": "workspace:*"
},
Expand Down
12 changes: 9 additions & 3 deletions packages/plugins/plugin-email/src/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-email/src/send-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Ships {{ shipAt | datetime }}</p>',
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(),
Expand Down
31 changes: 31 additions & 0 deletions packages/plugins/plugin-email/src/template-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,37 @@ describe('template-engine', () => {
it('escapes all standard HTML entities', () => {
expect(renderTemplate('{{s}}', { s: `&<>"'` })).toBe('&amp;&lt;&gt;&quot;&#39;');
});

// 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&amp;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('');
});
});
});

describe('requireVars', () => {
Expand Down
54 changes: 45 additions & 9 deletions packages/plugins/plugin-email/src/template-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,33 @@
* (e.g. when injecting pre-rendered HTML fragments such as URLs in
* `<a href="">`).
*
* 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
* Handlebars, but bringing it in costs ~50KB and pulls a parser at
* runtime; we resist that until a real use case demands it.
*/

const PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g;
import { formatValue } from '@objectstack/formula';

// 1=open(`{{`|`{{{`) 2=path 3=formatter(opt) 4=arg(opt) 5=close(`}}`|`}}}`).
// `[^'}]*?` is brace/quote-free, so it cannot run past the closing braces —
// the matcher stays linear (no ReDoS).
const PLACEHOLDER =
/(\{\{\{?)\s*([\w.]+)(?:\s*\|\s*(\w+)(?:\s*:\s*'?([^'}]*?)'?)?)?\s*(\}\}\}?)/g;

/** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */
export interface RenderOptions {
locale?: string;
timeZone?: string;
}

function lookup(data: Record<string, any>, path: string): unknown {
if (!path) return undefined;
Expand All @@ -43,15 +62,32 @@
* render as empty strings (no throw); call `requireVars()` first if
* you need strict validation.
*/
export function renderTemplate(template: string, data: Record<string, any>): string {
export function renderTemplate(
template: string,
data: Record<string, any>,
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, open: string, path: string, fname: string | undefined, farg: string | undefined, close: string) => {
const isUnescaped = open === '{{{' && close === '}}}';
const raw = lookup(data, path);
let str: string;
if (fname) {
// Formatted holes render '' for a missing value (the formula formatters
// treat null as empty), so they never emit "undefined".
const formatted = formatValue(fname, raw, farg, { 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);
},
);

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with '{{.' and with many repetitions of '\t'.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/src/contracts/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ export interface SendTemplateInput {
data?: Record<string, unknown>;
/** 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). */
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading