@objectstack/plugin-email 9.7.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 +38 -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/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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
18
33
|
}
|
|
19
|
-
function renderTemplate(template, data) {
|
|
34
|
+
function renderTemplate(template, data, opts = {}) {
|
|
20
35
|
if (!template) return "";
|
|
21
|
-
return template.replace(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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) {
|