@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/plugin-email@9.8.0 build /home/runner/work/framework/framework/packages/plugins/plugin-email
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
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.mjs 34.07 KB
14
- ESM dist/index.mjs.map 72.66 KB
15
- ESM ⚡️ Build success in 89ms
16
- CJS dist/index.js 35.81 KB
17
- CJS dist/index.js.map 74.00 KB
18
- CJS ⚡️ Build success in 94ms
13
+ ESM dist/index.mjs 40.23 KB
14
+ ESM dist/index.mjs.map 86.52 KB
15
+ ESM ⚡️ Build success in 137ms
16
+ CJS dist/index.js 41.99 KB
17
+ CJS dist/index.js.map 87.87 KB
18
+ CJS ⚡️ Build success in 147ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 18829ms
21
- DTS dist/index.d.mts 10.14 KB
22
- DTS dist/index.d.ts 10.14 KB
20
+ DTS ⚡️ Build success in 18230ms
21
+ DTS dist/index.d.mts 10.30 KB
22
+ DTS dist/index.d.ts 10.30 KB
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>): string;
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>): string;
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 PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
59
74
  }
60
- function renderTemplate(template, data) {
75
+ function renderTemplate(template, data, opts = {}) {
61
76
  if (!template) return "";
62
- return template.replace(PLACEHOLDER, (_match, open, path, close) => {
63
- const isUnescaped = open === "{{{" && close === "}}}";
64
- const raw = lookup(data, path);
65
- if (raw == null) return "";
66
- const str = typeof raw === "string" ? raw : String(raw);
67
- return isUnescaped ? str : escapeHtml(str);
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
- let persistedId;
236
- if (this.options.persistence) {
237
- try {
238
- const res = await this.options.persistence.insert(baseRow);
239
- persistedId = typeof res === "string" ? res : res?.id ?? id;
240
- } catch (err) {
241
- this.options.logger?.warn("EmailService: sys_email persist failed (non-fatal)", { error: err?.message });
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
- const rowId = persistedId ?? id;
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 subject = renderTemplate(row.subject, data);
320
- const html = renderTemplate(row.body_html, data);
321
- const text = row.body_text ? renderTemplate(row.body_text, data) : htmlToText(html);
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) {