@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/dist/index.mjs CHANGED
@@ -2,7 +2,22 @@
2
2
  import { SysEmail, SysEmailTemplate } from "@objectstack/platform-objects/audit";
3
3
 
4
4
  // src/template-engine.ts
5
- var PLACEHOLDER = /(\{\{\{?)\s*([\w.]+)\s*(\}?\}\})/g;
5
+ import { formatValue } from "@objectstack/formula";
6
+ var PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g;
7
+ var PATH_RE = /^[\w.]+$/;
8
+ var FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/;
9
+ function parseHole(inner) {
10
+ const pipe = inner.indexOf("|");
11
+ if (pipe === -1) {
12
+ const path2 = inner.trim();
13
+ return PATH_RE.test(path2) ? { path: path2 } : null;
14
+ }
15
+ const path = inner.slice(0, pipe).trim();
16
+ if (!PATH_RE.test(path)) return null;
17
+ const m = FILTER_RE.exec(inner.slice(pipe + 1).trim());
18
+ if (!m) return null;
19
+ return { path, formatter: m[1], arg: m[2] };
20
+ }
6
21
  function lookup(data, path) {
7
22
  if (!path) return void 0;
8
23
  const parts = path.split(".");
@@ -16,15 +31,29 @@ function lookup(data, path) {
16
31
  function escapeHtml(s) {
17
32
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
18
33
  }
19
- function renderTemplate(template, data) {
34
+ function renderTemplate(template, data, opts = {}) {
20
35
  if (!template) return "";
21
- return template.replace(PLACEHOLDER, (_match, open, path, close) => {
22
- const isUnescaped = open === "{{{" && close === "}}}";
23
- const raw = lookup(data, path);
24
- if (raw == null) return "";
25
- const str = typeof raw === "string" ? raw : String(raw);
26
- return isUnescaped ? str : escapeHtml(str);
27
- });
36
+ return template.replace(
37
+ PLACEHOLDER,
38
+ (match, open, inner, close) => {
39
+ const parsed = parseHole(inner);
40
+ if (!parsed) return match;
41
+ const isUnescaped = open === "{{{" && close === "}}}";
42
+ const raw = lookup(data, parsed.path);
43
+ let str;
44
+ if (parsed.formatter) {
45
+ const formatted = formatValue(parsed.formatter, raw, parsed.arg, {
46
+ locale: opts.locale,
47
+ timeZone: opts.timeZone
48
+ });
49
+ str = formatted !== void 0 ? formatted : raw == null ? "" : typeof raw === "string" ? raw : String(raw);
50
+ } else {
51
+ if (raw == null) return "";
52
+ str = typeof raw === "string" ? raw : String(raw);
53
+ }
54
+ return isUnescaped ? str : escapeHtml(str);
55
+ }
56
+ );
28
57
  }
29
58
  function requireVars(data, required) {
30
59
  const missing = required.filter((name) => lookup(data, name) == null);
@@ -113,6 +142,36 @@ function normalizeMessage(input, defaultFrom) {
113
142
  if (input.headers && Object.keys(input.headers).length > 0) msg.headers = input.headers;
114
143
  return msg;
115
144
  }
145
+ function splitAddresses(v) {
146
+ if (v == null) return [];
147
+ return String(v).split(",").map((s) => s.trim()).filter(Boolean);
148
+ }
149
+ function rowToNormalized(row) {
150
+ const to = splitAddresses(row.to_addresses);
151
+ if (to.length === 0) throw new Error("VALIDATION_FAILED: row has no to_addresses");
152
+ const from = String(row.from_address ?? "").trim();
153
+ if (!from) throw new Error("VALIDATION_FAILED: row has no from_address");
154
+ const subject = String(row.subject ?? "").trim();
155
+ if (!subject) throw new Error("VALIDATION_FAILED: row has no subject");
156
+ const text = row.body_text;
157
+ const html = row.body_html;
158
+ if ((text == null || text === "") && (html == null || html === "")) {
159
+ throw new Error("VALIDATION_FAILED: row has neither body_text nor body_html");
160
+ }
161
+ const msg = {
162
+ to,
163
+ from,
164
+ subject,
165
+ ...text != null && text !== "" ? { text: String(text) } : {},
166
+ ...html != null && html !== "" ? { html: String(html) } : {}
167
+ };
168
+ const cc = splitAddresses(row.cc_addresses);
169
+ if (cc.length > 0) msg.cc = cc;
170
+ const bcc = splitAddresses(row.bcc_addresses);
171
+ if (bcc.length > 0) msg.bcc = bcc;
172
+ if (row.reply_to) msg.replyTo = String(row.reply_to);
173
+ return msg;
174
+ }
116
175
  var LogTransport = class {
117
176
  constructor(logger) {
118
177
  this.logger = logger;
@@ -144,8 +203,20 @@ function newId() {
144
203
  var EmailService = class {
145
204
  constructor(options) {
146
205
  this.options = options;
206
+ /**
207
+ * Row ids the service is itself delivering (via `send()`). The
208
+ * EmailServicePlugin's sys_email afterInsert drain hook consults this
209
+ * set and skips these rows, so a service-originated `queued` insert is
210
+ * delivered exactly once (by `send()`) — never double-sent by the hook.
211
+ * App-originated raw inserts are absent here, so the hook handles them.
212
+ */
213
+ this.managedRowIds = /* @__PURE__ */ new Set();
147
214
  if (!options.transport) throw new Error("EmailService: transport is required");
148
215
  }
216
+ /** True when this row id is currently being delivered by `send()`. */
217
+ isServiceManaged(id) {
218
+ return this.managedRowIds.has(id);
219
+ }
149
220
  /** Wire (or replace) the template loader after construction. */
150
221
  setTemplateLoader(loader) {
151
222
  this.options.templateLoader = loader;
@@ -191,16 +262,30 @@ var EmailService = class {
191
262
  status: "queued",
192
263
  attempt_count: 0
193
264
  };
194
- let persistedId;
195
- if (this.options.persistence) {
196
- try {
197
- const res = await this.options.persistence.insert(baseRow);
198
- persistedId = typeof res === "string" ? res : res?.id ?? id;
199
- } catch (err) {
200
- this.options.logger?.warn("EmailService: sys_email persist failed (non-fatal)", { error: err?.message });
265
+ this.managedRowIds.add(id);
266
+ try {
267
+ let persistedId;
268
+ if (this.options.persistence) {
269
+ try {
270
+ const res = await this.options.persistence.insert(baseRow);
271
+ persistedId = typeof res === "string" ? res : res?.id ?? id;
272
+ if (persistedId !== id) this.managedRowIds.add(persistedId);
273
+ } catch (err) {
274
+ this.options.logger?.warn("EmailService: sys_email persist failed (non-fatal)", { error: err?.message });
275
+ }
201
276
  }
277
+ const rowId = persistedId ?? id;
278
+ return await this.deliverNormalized(rowId, normalized);
279
+ } finally {
280
+ this.managedRowIds.delete(id);
202
281
  }
203
- const rowId = persistedId ?? id;
282
+ }
283
+ /**
284
+ * Deliver a normalized message through the transport (with retry) and
285
+ * finalize the persisted `sys_email` row (`sent` + message_id + sent_at,
286
+ * or `failed` + error). Shared by `send()` and `deliverPersistedRow()`.
287
+ */
288
+ async deliverNormalized(rowId, normalized) {
204
289
  const maxAttempts = (this.options.retries ?? 0) + 1;
205
290
  let lastError;
206
291
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -230,6 +315,28 @@ var EmailService = class {
230
315
  });
231
316
  return { id: rowId, status: "failed", error: errMessage };
232
317
  }
318
+ /**
319
+ * Deliver an ALREADY-PERSISTED `sys_email` row (the outbox-drain path).
320
+ *
321
+ * An app (e.g. a sandboxed action that can only `api.write`, never reach
322
+ * the email service) inserts a `sys_email` row as `status:'queued'`; the
323
+ * plugin's afterInsert hook calls this to actually transmit it. Unlike
324
+ * `send()`, this does NOT insert a new row — it reconstructs the message
325
+ * from the row columns and finalizes that same row in place.
326
+ */
327
+ async deliverPersistedRow(row) {
328
+ const rowId = String(row?.id ?? "");
329
+ if (!rowId) throw new Error("deliverPersistedRow: row.id is required");
330
+ let normalized;
331
+ try {
332
+ normalized = rowToNormalized(row);
333
+ } catch (err) {
334
+ const errMessage = String(err?.message ?? err ?? "invalid row").slice(0, 1e3);
335
+ await this.updateRow(rowId, { status: "failed", error: errMessage, attempt_count: 0 });
336
+ return { id: rowId, status: "failed", error: errMessage };
337
+ }
338
+ return this.deliverNormalized(rowId, normalized);
339
+ }
233
340
  async updateRow(id, patch) {
234
341
  if (!this.options.persistence?.update) return;
235
342
  try {
@@ -275,9 +382,13 @@ var EmailService = class {
275
382
  this.options.logger?.warn("EmailService: variables_json parse failed (ignored)", { template: input.template });
276
383
  }
277
384
  }
278
- const subject = renderTemplate(row.subject, data);
279
- const html = renderTemplate(row.body_html, data);
280
- const text = row.body_text ? renderTemplate(row.body_text, data) : htmlToText(html);
385
+ const renderOpts = {
386
+ ...input.locale ? { locale: input.locale } : {},
387
+ ...input.timezone ? { timeZone: input.timezone } : {}
388
+ };
389
+ const subject = renderTemplate(row.subject, data, renderOpts);
390
+ const html = renderTemplate(row.body_html, data, renderOpts);
391
+ const text = row.body_text ? renderTemplate(row.body_text, data, renderOpts) : htmlToText(html);
281
392
  const from = input.from ?? (row.from_address ? { address: row.from_address, ...row.from_name ? { name: row.from_name } : {} } : void 0);
282
393
  const sendInput = {
283
394
  to: input.to,
@@ -751,6 +862,47 @@ var EmailServicePlugin = class {
751
862
  if (persistence) this.service.setPersistence(persistence);
752
863
  this.service.setTemplateLoader(templateLoader);
753
864
  ctx.logger.info("EmailServicePlugin: sys_email persistence + template loader enabled");
865
+ if (persistence && typeof engine.registerHook === "function") {
866
+ const svc = this.service;
867
+ const DRAIN_PKG = "com.objectstack.service.email.drain";
868
+ if (typeof engine.unregisterHooksByPackage === "function") {
869
+ engine.unregisterHooksByPackage(DRAIN_PKG);
870
+ }
871
+ engine.registerHook(
872
+ "afterInsert",
873
+ async (hookCtx) => {
874
+ try {
875
+ if (hookCtx?.object !== "sys_email") return;
876
+ const row = hookCtx?.result;
877
+ if (!row || typeof row !== "object") return;
878
+ if (row.status !== "queued" || row.message_id) return;
879
+ const rowId = row.id != null ? String(row.id) : "";
880
+ if (!rowId || svc.isServiceManaged(rowId)) return;
881
+ setTimeout(() => {
882
+ void (async () => {
883
+ try {
884
+ const rows = await engine.find("sys_email", {
885
+ where: { id: rowId },
886
+ limit: 1,
887
+ context: SYSTEM_CTX
888
+ });
889
+ const fresh = Array.isArray(rows) ? rows[0] : rows?.data?.[0];
890
+ const target = fresh ?? row;
891
+ if (target.status !== "queued" || target.message_id) return;
892
+ await svc.deliverPersistedRow(target);
893
+ } catch (err) {
894
+ ctx.logger.warn(`EmailServicePlugin: outbox drain failed for ${rowId}: ${err?.message ?? err}`);
895
+ }
896
+ })();
897
+ }, 0);
898
+ } catch (err) {
899
+ ctx.logger.warn(`EmailServicePlugin: outbox drain hook error: ${err?.message ?? err}`);
900
+ }
901
+ },
902
+ { packageId: DRAIN_PKG }
903
+ );
904
+ ctx.logger.info("EmailServicePlugin: sys_email outbox drain hook installed");
905
+ }
754
906
  try {
755
907
  const queue = ctx.getService("queue");
756
908
  if (queue && typeof queue.subscribe === "function" && this.service) {