@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/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @objectstack/plugin-email@9.
|
|
2
|
+
> @objectstack/plugin-email@9.9.0 build /home/runner/work/framework/framework/packages/plugins/plugin-email
|
|
3
3
|
> tsup --config ../../../tsup.config.ts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
12
|
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[
|
|
14
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[
|
|
15
|
-
[32mESM[39m ⚡️ Build success in
|
|
16
|
-
[32mCJS[39m [1mdist/index.js [22m[
|
|
17
|
-
[32mCJS[39m [1mdist/index.js.map [22m[
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in
|
|
13
|
+
[32mESM[39m [1mdist/index.mjs [22m[32m40.23 KB[39m
|
|
14
|
+
[32mESM[39m [1mdist/index.mjs.map [22m[32m86.52 KB[39m
|
|
15
|
+
[32mESM[39m ⚡️ Build success in 137ms
|
|
16
|
+
[32mCJS[39m [1mdist/index.js [22m[32m41.99 KB[39m
|
|
17
|
+
[32mCJS[39m [1mdist/index.js.map [22m[32m87.87 KB[39m
|
|
18
|
+
[32mCJS[39m ⚡️ Build success in 147ms
|
|
19
19
|
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.mts [22m[32m10.
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m10.
|
|
20
|
+
[32mDTS[39m ⚡️ Build success in 18230ms
|
|
21
|
+
[32mDTS[39m [1mdist/index.d.mts [22m[32m10.30 KB[39m
|
|
22
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m10.30 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @objectstack/plugin-email
|
|
2
2
|
|
|
3
|
+
## 9.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 575448d: 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.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Updated dependencies [84249a4]
|
|
17
|
+
- Updated dependencies [11af299]
|
|
18
|
+
- Updated dependencies [d5774b5]
|
|
19
|
+
- Updated dependencies [134043a]
|
|
20
|
+
- Updated dependencies [90108e0]
|
|
21
|
+
- Updated dependencies [9afeb2d]
|
|
22
|
+
- Updated dependencies [6bec07e]
|
|
23
|
+
- Updated dependencies [601cc11]
|
|
24
|
+
- Updated dependencies [d99a75a]
|
|
25
|
+
- Updated dependencies [575448d]
|
|
26
|
+
- @objectstack/spec@9.9.0
|
|
27
|
+
- @objectstack/core@9.9.0
|
|
28
|
+
- @objectstack/formula@9.9.0
|
|
29
|
+
- @objectstack/platform-objects@9.9.0
|
|
30
|
+
|
|
3
31
|
## 9.8.0
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/dist/index.d.mts
CHANGED
|
@@ -149,12 +149,17 @@ interface EmailServiceOptions {
|
|
|
149
149
|
defaultTemplateContext?: Record<string, unknown>;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */
|
|
153
|
+
interface RenderOptions {
|
|
154
|
+
locale?: string;
|
|
155
|
+
timeZone?: string;
|
|
156
|
+
}
|
|
152
157
|
/**
|
|
153
158
|
* Render `template` with values from `data`. Missing placeholders
|
|
154
159
|
* render as empty strings (no throw); call `requireVars()` first if
|
|
155
160
|
* you need strict validation.
|
|
156
161
|
*/
|
|
157
|
-
declare function renderTemplate(template: string, data: Record<string, any
|
|
162
|
+
declare function renderTemplate(template: string, data: Record<string, any>, opts?: RenderOptions): string;
|
|
158
163
|
/**
|
|
159
164
|
* Throw `Error('MISSING_VARIABLES: a, b')` when required vars are
|
|
160
165
|
* absent from `data`. Used by `IEmailService.sendTemplate()` to
|
package/dist/index.d.ts
CHANGED
|
@@ -149,12 +149,17 @@ interface EmailServiceOptions {
|
|
|
149
149
|
defaultTemplateContext?: Record<string, unknown>;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */
|
|
153
|
+
interface RenderOptions {
|
|
154
|
+
locale?: string;
|
|
155
|
+
timeZone?: string;
|
|
156
|
+
}
|
|
152
157
|
/**
|
|
153
158
|
* Render `template` with values from `data`. Missing placeholders
|
|
154
159
|
* render as empty strings (no throw); call `requireVars()` first if
|
|
155
160
|
* you need strict validation.
|
|
156
161
|
*/
|
|
157
|
-
declare function renderTemplate(template: string, data: Record<string, any
|
|
162
|
+
declare function renderTemplate(template: string, data: Record<string, any>, opts?: RenderOptions): string;
|
|
158
163
|
/**
|
|
159
164
|
* Throw `Error('MISSING_VARIABLES: a, b')` when required vars are
|
|
160
165
|
* absent from `data`. Used by `IEmailService.sendTemplate()` to
|
package/dist/index.js
CHANGED
|
@@ -43,7 +43,22 @@ module.exports = __toCommonJS(index_exports);
|
|
|
43
43
|
var import_audit = require("@objectstack/platform-objects/audit");
|
|
44
44
|
|
|
45
45
|
// src/template-engine.ts
|
|
46
|
-
var
|
|
46
|
+
var import_formula = require("@objectstack/formula");
|
|
47
|
+
var PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g;
|
|
48
|
+
var PATH_RE = /^[\w.]+$/;
|
|
49
|
+
var FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/;
|
|
50
|
+
function parseHole(inner) {
|
|
51
|
+
const pipe = inner.indexOf("|");
|
|
52
|
+
if (pipe === -1) {
|
|
53
|
+
const path2 = inner.trim();
|
|
54
|
+
return PATH_RE.test(path2) ? { path: path2 } : null;
|
|
55
|
+
}
|
|
56
|
+
const path = inner.slice(0, pipe).trim();
|
|
57
|
+
if (!PATH_RE.test(path)) return null;
|
|
58
|
+
const m = FILTER_RE.exec(inner.slice(pipe + 1).trim());
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
return { path, formatter: m[1], arg: m[2] };
|
|
61
|
+
}
|
|
47
62
|
function lookup(data, path) {
|
|
48
63
|
if (!path) return void 0;
|
|
49
64
|
const parts = path.split(".");
|
|
@@ -57,15 +72,29 @@ function lookup(data, path) {
|
|
|
57
72
|
function escapeHtml(s) {
|
|
58
73
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
59
74
|
}
|
|
60
|
-
function renderTemplate(template, data) {
|
|
75
|
+
function renderTemplate(template, data, opts = {}) {
|
|
61
76
|
if (!template) return "";
|
|
62
|
-
return template.replace(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
return template.replace(
|
|
78
|
+
PLACEHOLDER,
|
|
79
|
+
(match, open, inner, close) => {
|
|
80
|
+
const parsed = parseHole(inner);
|
|
81
|
+
if (!parsed) return match;
|
|
82
|
+
const isUnescaped = open === "{{{" && close === "}}}";
|
|
83
|
+
const raw = lookup(data, parsed.path);
|
|
84
|
+
let str;
|
|
85
|
+
if (parsed.formatter) {
|
|
86
|
+
const formatted = (0, import_formula.formatValue)(parsed.formatter, raw, parsed.arg, {
|
|
87
|
+
locale: opts.locale,
|
|
88
|
+
timeZone: opts.timeZone
|
|
89
|
+
});
|
|
90
|
+
str = formatted !== void 0 ? formatted : raw == null ? "" : typeof raw === "string" ? raw : String(raw);
|
|
91
|
+
} else {
|
|
92
|
+
if (raw == null) return "";
|
|
93
|
+
str = typeof raw === "string" ? raw : String(raw);
|
|
94
|
+
}
|
|
95
|
+
return isUnescaped ? str : escapeHtml(str);
|
|
96
|
+
}
|
|
97
|
+
);
|
|
69
98
|
}
|
|
70
99
|
function requireVars(data, required) {
|
|
71
100
|
const missing = required.filter((name) => lookup(data, name) == null);
|
|
@@ -154,6 +183,36 @@ function normalizeMessage(input, defaultFrom) {
|
|
|
154
183
|
if (input.headers && Object.keys(input.headers).length > 0) msg.headers = input.headers;
|
|
155
184
|
return msg;
|
|
156
185
|
}
|
|
186
|
+
function splitAddresses(v) {
|
|
187
|
+
if (v == null) return [];
|
|
188
|
+
return String(v).split(",").map((s) => s.trim()).filter(Boolean);
|
|
189
|
+
}
|
|
190
|
+
function rowToNormalized(row) {
|
|
191
|
+
const to = splitAddresses(row.to_addresses);
|
|
192
|
+
if (to.length === 0) throw new Error("VALIDATION_FAILED: row has no to_addresses");
|
|
193
|
+
const from = String(row.from_address ?? "").trim();
|
|
194
|
+
if (!from) throw new Error("VALIDATION_FAILED: row has no from_address");
|
|
195
|
+
const subject = String(row.subject ?? "").trim();
|
|
196
|
+
if (!subject) throw new Error("VALIDATION_FAILED: row has no subject");
|
|
197
|
+
const text = row.body_text;
|
|
198
|
+
const html = row.body_html;
|
|
199
|
+
if ((text == null || text === "") && (html == null || html === "")) {
|
|
200
|
+
throw new Error("VALIDATION_FAILED: row has neither body_text nor body_html");
|
|
201
|
+
}
|
|
202
|
+
const msg = {
|
|
203
|
+
to,
|
|
204
|
+
from,
|
|
205
|
+
subject,
|
|
206
|
+
...text != null && text !== "" ? { text: String(text) } : {},
|
|
207
|
+
...html != null && html !== "" ? { html: String(html) } : {}
|
|
208
|
+
};
|
|
209
|
+
const cc = splitAddresses(row.cc_addresses);
|
|
210
|
+
if (cc.length > 0) msg.cc = cc;
|
|
211
|
+
const bcc = splitAddresses(row.bcc_addresses);
|
|
212
|
+
if (bcc.length > 0) msg.bcc = bcc;
|
|
213
|
+
if (row.reply_to) msg.replyTo = String(row.reply_to);
|
|
214
|
+
return msg;
|
|
215
|
+
}
|
|
157
216
|
var LogTransport = class {
|
|
158
217
|
constructor(logger) {
|
|
159
218
|
this.logger = logger;
|
|
@@ -185,8 +244,20 @@ function newId() {
|
|
|
185
244
|
var EmailService = class {
|
|
186
245
|
constructor(options) {
|
|
187
246
|
this.options = options;
|
|
247
|
+
/**
|
|
248
|
+
* Row ids the service is itself delivering (via `send()`). The
|
|
249
|
+
* EmailServicePlugin's sys_email afterInsert drain hook consults this
|
|
250
|
+
* set and skips these rows, so a service-originated `queued` insert is
|
|
251
|
+
* delivered exactly once (by `send()`) — never double-sent by the hook.
|
|
252
|
+
* App-originated raw inserts are absent here, so the hook handles them.
|
|
253
|
+
*/
|
|
254
|
+
this.managedRowIds = /* @__PURE__ */ new Set();
|
|
188
255
|
if (!options.transport) throw new Error("EmailService: transport is required");
|
|
189
256
|
}
|
|
257
|
+
/** True when this row id is currently being delivered by `send()`. */
|
|
258
|
+
isServiceManaged(id) {
|
|
259
|
+
return this.managedRowIds.has(id);
|
|
260
|
+
}
|
|
190
261
|
/** Wire (or replace) the template loader after construction. */
|
|
191
262
|
setTemplateLoader(loader) {
|
|
192
263
|
this.options.templateLoader = loader;
|
|
@@ -232,16 +303,30 @@ var EmailService = class {
|
|
|
232
303
|
status: "queued",
|
|
233
304
|
attempt_count: 0
|
|
234
305
|
};
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
306
|
+
this.managedRowIds.add(id);
|
|
307
|
+
try {
|
|
308
|
+
let persistedId;
|
|
309
|
+
if (this.options.persistence) {
|
|
310
|
+
try {
|
|
311
|
+
const res = await this.options.persistence.insert(baseRow);
|
|
312
|
+
persistedId = typeof res === "string" ? res : res?.id ?? id;
|
|
313
|
+
if (persistedId !== id) this.managedRowIds.add(persistedId);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
this.options.logger?.warn("EmailService: sys_email persist failed (non-fatal)", { error: err?.message });
|
|
316
|
+
}
|
|
242
317
|
}
|
|
318
|
+
const rowId = persistedId ?? id;
|
|
319
|
+
return await this.deliverNormalized(rowId, normalized);
|
|
320
|
+
} finally {
|
|
321
|
+
this.managedRowIds.delete(id);
|
|
243
322
|
}
|
|
244
|
-
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Deliver a normalized message through the transport (with retry) and
|
|
326
|
+
* finalize the persisted `sys_email` row (`sent` + message_id + sent_at,
|
|
327
|
+
* or `failed` + error). Shared by `send()` and `deliverPersistedRow()`.
|
|
328
|
+
*/
|
|
329
|
+
async deliverNormalized(rowId, normalized) {
|
|
245
330
|
const maxAttempts = (this.options.retries ?? 0) + 1;
|
|
246
331
|
let lastError;
|
|
247
332
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -271,6 +356,28 @@ var EmailService = class {
|
|
|
271
356
|
});
|
|
272
357
|
return { id: rowId, status: "failed", error: errMessage };
|
|
273
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Deliver an ALREADY-PERSISTED `sys_email` row (the outbox-drain path).
|
|
361
|
+
*
|
|
362
|
+
* An app (e.g. a sandboxed action that can only `api.write`, never reach
|
|
363
|
+
* the email service) inserts a `sys_email` row as `status:'queued'`; the
|
|
364
|
+
* plugin's afterInsert hook calls this to actually transmit it. Unlike
|
|
365
|
+
* `send()`, this does NOT insert a new row — it reconstructs the message
|
|
366
|
+
* from the row columns and finalizes that same row in place.
|
|
367
|
+
*/
|
|
368
|
+
async deliverPersistedRow(row) {
|
|
369
|
+
const rowId = String(row?.id ?? "");
|
|
370
|
+
if (!rowId) throw new Error("deliverPersistedRow: row.id is required");
|
|
371
|
+
let normalized;
|
|
372
|
+
try {
|
|
373
|
+
normalized = rowToNormalized(row);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
const errMessage = String(err?.message ?? err ?? "invalid row").slice(0, 1e3);
|
|
376
|
+
await this.updateRow(rowId, { status: "failed", error: errMessage, attempt_count: 0 });
|
|
377
|
+
return { id: rowId, status: "failed", error: errMessage };
|
|
378
|
+
}
|
|
379
|
+
return this.deliverNormalized(rowId, normalized);
|
|
380
|
+
}
|
|
274
381
|
async updateRow(id, patch) {
|
|
275
382
|
if (!this.options.persistence?.update) return;
|
|
276
383
|
try {
|
|
@@ -316,9 +423,13 @@ var EmailService = class {
|
|
|
316
423
|
this.options.logger?.warn("EmailService: variables_json parse failed (ignored)", { template: input.template });
|
|
317
424
|
}
|
|
318
425
|
}
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
426
|
+
const renderOpts = {
|
|
427
|
+
...input.locale ? { locale: input.locale } : {},
|
|
428
|
+
...input.timezone ? { timeZone: input.timezone } : {}
|
|
429
|
+
};
|
|
430
|
+
const subject = renderTemplate(row.subject, data, renderOpts);
|
|
431
|
+
const html = renderTemplate(row.body_html, data, renderOpts);
|
|
432
|
+
const text = row.body_text ? renderTemplate(row.body_text, data, renderOpts) : htmlToText(html);
|
|
322
433
|
const from = input.from ?? (row.from_address ? { address: row.from_address, ...row.from_name ? { name: row.from_name } : {} } : void 0);
|
|
323
434
|
const sendInput = {
|
|
324
435
|
to: input.to,
|
|
@@ -792,6 +903,47 @@ var EmailServicePlugin = class {
|
|
|
792
903
|
if (persistence) this.service.setPersistence(persistence);
|
|
793
904
|
this.service.setTemplateLoader(templateLoader);
|
|
794
905
|
ctx.logger.info("EmailServicePlugin: sys_email persistence + template loader enabled");
|
|
906
|
+
if (persistence && typeof engine.registerHook === "function") {
|
|
907
|
+
const svc = this.service;
|
|
908
|
+
const DRAIN_PKG = "com.objectstack.service.email.drain";
|
|
909
|
+
if (typeof engine.unregisterHooksByPackage === "function") {
|
|
910
|
+
engine.unregisterHooksByPackage(DRAIN_PKG);
|
|
911
|
+
}
|
|
912
|
+
engine.registerHook(
|
|
913
|
+
"afterInsert",
|
|
914
|
+
async (hookCtx) => {
|
|
915
|
+
try {
|
|
916
|
+
if (hookCtx?.object !== "sys_email") return;
|
|
917
|
+
const row = hookCtx?.result;
|
|
918
|
+
if (!row || typeof row !== "object") return;
|
|
919
|
+
if (row.status !== "queued" || row.message_id) return;
|
|
920
|
+
const rowId = row.id != null ? String(row.id) : "";
|
|
921
|
+
if (!rowId || svc.isServiceManaged(rowId)) return;
|
|
922
|
+
setTimeout(() => {
|
|
923
|
+
void (async () => {
|
|
924
|
+
try {
|
|
925
|
+
const rows = await engine.find("sys_email", {
|
|
926
|
+
where: { id: rowId },
|
|
927
|
+
limit: 1,
|
|
928
|
+
context: SYSTEM_CTX
|
|
929
|
+
});
|
|
930
|
+
const fresh = Array.isArray(rows) ? rows[0] : rows?.data?.[0];
|
|
931
|
+
const target = fresh ?? row;
|
|
932
|
+
if (target.status !== "queued" || target.message_id) return;
|
|
933
|
+
await svc.deliverPersistedRow(target);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
ctx.logger.warn(`EmailServicePlugin: outbox drain failed for ${rowId}: ${err?.message ?? err}`);
|
|
936
|
+
}
|
|
937
|
+
})();
|
|
938
|
+
}, 0);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
ctx.logger.warn(`EmailServicePlugin: outbox drain hook error: ${err?.message ?? err}`);
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
{ packageId: DRAIN_PKG }
|
|
944
|
+
);
|
|
945
|
+
ctx.logger.info("EmailServicePlugin: sys_email outbox drain hook installed");
|
|
946
|
+
}
|
|
795
947
|
try {
|
|
796
948
|
const queue = ctx.getService("queue");
|
|
797
949
|
if (queue && typeof queue.subscribe === "function" && this.service) {
|