@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +28 -0
- package/dist/index.d.mts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +172 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/email-plugin.ts +59 -0
- package/src/email-service.test.ts +77 -0
- package/src/email-service.ts +116 -11
- package/src/send-template.test.ts +28 -0
- package/src/template-engine.test.ts +43 -0
- package/src/template-engine.ts +77 -9
package/src/email-service.ts
CHANGED
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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('&<>"'');
|
|
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&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', () => {
|
package/src/template-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
/**
|