@objectstack/plugin-email 9.8.0 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,6 +95,48 @@ export function normalizeMessage(
95
95
  return msg;
96
96
  }
97
97
 
98
+ /** Split a persisted comma-separated address column back into a list. */
99
+ function splitAddresses(v: unknown): string[] {
100
+ if (v == null) return [];
101
+ return String(v)
102
+ .split(',')
103
+ .map((s) => s.trim())
104
+ .filter(Boolean);
105
+ }
106
+
107
+ /**
108
+ * Reconstruct a NormalizedEmailMessage from a persisted `sys_email` row
109
+ * (the outbox-drain path). Addresses are already canonicalized at insert
110
+ * time, so this re-splits the stored columns rather than re-validating.
111
+ * Throws when the row lacks the minimum fields needed to send.
112
+ */
113
+ export function rowToNormalized(row: Record<string, any>): NormalizedEmailMessage {
114
+ const to = splitAddresses(row.to_addresses);
115
+ if (to.length === 0) throw new Error('VALIDATION_FAILED: row has no to_addresses');
116
+ const from = String(row.from_address ?? '').trim();
117
+ if (!from) throw new Error('VALIDATION_FAILED: row has no from_address');
118
+ const subject = String(row.subject ?? '').trim();
119
+ if (!subject) throw new Error('VALIDATION_FAILED: row has no subject');
120
+ const text = row.body_text;
121
+ const html = row.body_html;
122
+ if ((text == null || text === '') && (html == null || html === '')) {
123
+ throw new Error('VALIDATION_FAILED: row has neither body_text nor body_html');
124
+ }
125
+ const msg: NormalizedEmailMessage = {
126
+ to,
127
+ from,
128
+ subject,
129
+ ...(text != null && text !== '' ? { text: String(text) } : {}),
130
+ ...(html != null && html !== '' ? { html: String(html) } : {}),
131
+ };
132
+ const cc = splitAddresses(row.cc_addresses);
133
+ if (cc.length > 0) msg.cc = cc;
134
+ const bcc = splitAddresses(row.bcc_addresses);
135
+ if (bcc.length > 0) msg.bcc = bcc;
136
+ if (row.reply_to) msg.replyTo = String(row.reply_to);
137
+ return msg;
138
+ }
139
+
98
140
  /**
99
141
  * Development transport — never actually sends. Logs to the provided
100
142
  * logger and returns a synthetic Message-ID. Useful for local dev,
@@ -186,10 +228,24 @@ export interface EmailServiceOptions {
186
228
  * id when persistence is disabled).
187
229
  */
188
230
  export class EmailService implements IEmailService {
231
+ /**
232
+ * Row ids the service is itself delivering (via `send()`). The
233
+ * EmailServicePlugin's sys_email afterInsert drain hook consults this
234
+ * set and skips these rows, so a service-originated `queued` insert is
235
+ * delivered exactly once (by `send()`) — never double-sent by the hook.
236
+ * App-originated raw inserts are absent here, so the hook handles them.
237
+ */
238
+ private readonly managedRowIds = new Set<string>();
239
+
189
240
  constructor(public options: EmailServiceOptions) {
190
241
  if (!options.transport) throw new Error('EmailService: transport is required');
191
242
  }
192
243
 
244
+ /** True when this row id is currently being delivered by `send()`. */
245
+ isServiceManaged(id: string): boolean {
246
+ return this.managedRowIds.has(id);
247
+ }
248
+
193
249
  /** Wire (or replace) the template loader after construction. */
194
250
  setTemplateLoader(loader: TemplateLoader): void {
195
251
  this.options.templateLoader = loader;
@@ -242,17 +298,37 @@ export class EmailService implements IEmailService {
242
298
  attempt_count: 0,
243
299
  };
244
300
 
245
- let persistedId: string | undefined;
246
- if (this.options.persistence) {
247
- try {
248
- const res = await this.options.persistence.insert(baseRow);
249
- persistedId = typeof res === 'string' ? res : res?.id ?? id;
250
- } catch (err: any) {
251
- this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message });
301
+ // Reserve the row id BEFORE persistence.insert so the drain hook
302
+ // (which fires synchronously inside that insert) sees it as managed
303
+ // and skips it — `send()` owns this row's delivery.
304
+ this.managedRowIds.add(id);
305
+ try {
306
+ let persistedId: string | undefined;
307
+ if (this.options.persistence) {
308
+ try {
309
+ const res = await this.options.persistence.insert(baseRow);
310
+ persistedId = typeof res === 'string' ? res : res?.id ?? id;
311
+ if (persistedId !== id) this.managedRowIds.add(persistedId);
312
+ } catch (err: any) {
313
+ this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message });
314
+ }
252
315
  }
316
+ const rowId = persistedId ?? id;
317
+ return await this.deliverNormalized(rowId, normalized);
318
+ } finally {
319
+ this.managedRowIds.delete(id);
253
320
  }
254
- const rowId = persistedId ?? id;
321
+ }
255
322
 
323
+ /**
324
+ * Deliver a normalized message through the transport (with retry) and
325
+ * finalize the persisted `sys_email` row (`sent` + message_id + sent_at,
326
+ * or `failed` + error). Shared by `send()` and `deliverPersistedRow()`.
327
+ */
328
+ private async deliverNormalized(
329
+ rowId: string,
330
+ normalized: NormalizedEmailMessage,
331
+ ): Promise<SendEmailResult> {
256
332
  const maxAttempts = (this.options.retries ?? 0) + 1;
257
333
  let lastError: any;
258
334
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -284,6 +360,29 @@ export class EmailService implements IEmailService {
284
360
  return { id: rowId, status: 'failed', error: errMessage };
285
361
  }
286
362
 
363
+ /**
364
+ * Deliver an ALREADY-PERSISTED `sys_email` row (the outbox-drain path).
365
+ *
366
+ * An app (e.g. a sandboxed action that can only `api.write`, never reach
367
+ * the email service) inserts a `sys_email` row as `status:'queued'`; the
368
+ * plugin's afterInsert hook calls this to actually transmit it. Unlike
369
+ * `send()`, this does NOT insert a new row — it reconstructs the message
370
+ * from the row columns and finalizes that same row in place.
371
+ */
372
+ async deliverPersistedRow(row: Record<string, any>): Promise<SendEmailResult> {
373
+ const rowId = String(row?.id ?? '');
374
+ if (!rowId) throw new Error('deliverPersistedRow: row.id is required');
375
+ let normalized: NormalizedEmailMessage;
376
+ try {
377
+ normalized = rowToNormalized(row);
378
+ } catch (err: any) {
379
+ const errMessage = String(err?.message ?? err ?? 'invalid row').slice(0, 1000);
380
+ await this.updateRow(rowId, { status: 'failed', error: errMessage, attempt_count: 0 });
381
+ return { id: rowId, status: 'failed', error: errMessage };
382
+ }
383
+ return this.deliverNormalized(rowId, normalized);
384
+ }
385
+
287
386
  private async updateRow(id: string, patch: Record<string, any>): Promise<void> {
288
387
  if (!this.options.persistence?.update) return;
289
388
  try {
@@ -333,10 +432,16 @@ export class EmailService implements IEmailService {
333
432
  }
334
433
  }
335
434
 
336
- const subject = renderTemplate(row.subject, data);
337
- const html = renderTemplate(row.body_html, data);
435
+ // Render holes with the recipient's locale + reference timezone so
436
+ // `{{ ts | datetime }}` shows the right wall-clock (ADR-0053 Phase 2).
437
+ const renderOpts = {
438
+ ...(input.locale ? { locale: input.locale } : {}),
439
+ ...(input.timezone ? { timeZone: input.timezone } : {}),
440
+ };
441
+ const subject = renderTemplate(row.subject, data, renderOpts);
442
+ const html = renderTemplate(row.body_html, data, renderOpts);
338
443
  const text = row.body_text
339
- ? renderTemplate(row.body_text, data)
444
+ ? renderTemplate(row.body_text, data, renderOpts)
340
445
  : htmlToText(html);
341
446
 
342
447
  const from: EmailAddress | undefined = input.from
@@ -60,6 +60,34 @@ describe('EmailService.sendTemplate', () => {
60
60
  expect(msg.text).toContain('reset: https://x.com/r/abc');
61
61
  });
62
62
 
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
+
63
91
  it('throws TEMPLATE_NOT_FOUND when loader returns null', async () => {
64
92
  const svc = new EmailService({
65
93
  transport: new CaptureTransport(),
@@ -37,6 +37,49 @@ describe('template-engine', () => {
37
37
  it('escapes all standard HTML entities', () => {
38
38
  expect(renderTemplate('{{s}}', { s: `&<>"'` })).toBe('&amp;&lt;&gt;&quot;&#39;');
39
39
  });
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
+ });
40
83
  });
41
84
 
42
85
  describe('requireVars', () => {
@@ -9,6 +9,13 @@
9
9
  * (e.g. when injecting pre-rendered HTML fragments such as URLs in
10
10
  * `<a href="">`).
11
11
  *
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
+ *
12
19
  * Deliberately tiny (no loops / conditionals / partials) — the design
13
20
  * stance is that email templates SHOULD be data-only renderings; any
14
21
  * branching belongs in the caller. If we ever need more, swap for
@@ -16,7 +23,46 @@
16
23
  * runtime; we resist that until a real use case demands it.
17
24
  */
18
25
 
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
+ }
20
66
 
21
67
  function lookup(data: Record<string, any>, path: string): unknown {
22
68
  if (!path) return undefined;
@@ -43,15 +89,37 @@ function escapeHtml(s: string): string {
43
89
  * render as empty strings (no throw); call `requireVars()` first if
44
90
  * you need strict validation.
45
91
  */
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 {
47
97
  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
+ );
55
123
  }
56
124
 
57
125
  /**