mailery 0.5.1 → 0.7.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/README.md +3 -1
- package/dist/admin/spa/{index-Dmsla2eL.js → index-CjQTOX9H.js} +8 -8
- package/dist/admin/spa/index-CjQTOX9H.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-Bn28OPMU.js → template-editor-yH0Oz4Ix.js} +105 -105
- package/dist/admin/spa/template-editor-yH0Oz4Ix.js.map +1 -0
- package/dist/index.cjs +453 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -4
- package/dist/index.d.ts +26 -4
- package/dist/index.js +451 -31
- package/dist/index.js.map +1 -1
- package/dist/{null-BYB6v4Iv.d.cts → null-B0rPgE5_.d.cts} +126 -3
- package/dist/{null-BYB6v4Iv.d.ts → null-B0rPgE5_.d.ts} +126 -3
- package/dist/testing.cjs +305 -17
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +305 -17
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/dist/admin/spa/index-Dmsla2eL.js.map +0 -1
- package/dist/admin/spa/template-editor-Bn28OPMU.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { ObjectId } from 'mongodb';
|
|
2
3
|
import crypto2 from 'crypto';
|
|
3
4
|
import sgMail from '@sendgrid/mail';
|
|
4
|
-
import { z } from 'zod';
|
|
5
5
|
import IORedis from 'ioredis';
|
|
6
6
|
import Handlebars from 'handlebars';
|
|
7
7
|
import { convert } from 'html-to-text';
|
|
@@ -31,6 +31,55 @@ var __export = (target, all) => {
|
|
|
31
31
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
// src/server/adapters/vars.ts
|
|
35
|
+
var vars_exports = {};
|
|
36
|
+
__export(vars_exports, {
|
|
37
|
+
RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
|
|
38
|
+
assertNoReservedVarKeys: () => assertNoReservedVarKeys,
|
|
39
|
+
defineVars: () => defineVars,
|
|
40
|
+
resolveVars: () => resolveVars,
|
|
41
|
+
varsJsonSchema: () => varsJsonSchema
|
|
42
|
+
});
|
|
43
|
+
function defineVars(adapter) {
|
|
44
|
+
return adapter;
|
|
45
|
+
}
|
|
46
|
+
function assertNoReservedVarKeys(adapter) {
|
|
47
|
+
const json = varsJsonSchema(adapter);
|
|
48
|
+
const props = json && typeof json === "object" ? json.properties : void 0;
|
|
49
|
+
if (!props) return;
|
|
50
|
+
const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
|
|
51
|
+
if (clashes.length > 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function varsJsonSchema(adapter) {
|
|
58
|
+
return z.toJSONSchema(adapter.schema, { io: "output" });
|
|
59
|
+
}
|
|
60
|
+
async function resolveVars(adapter, contact, info) {
|
|
61
|
+
if (!adapter) return {};
|
|
62
|
+
const resolved = await adapter.resolve(contact, info);
|
|
63
|
+
if (!resolved || typeof resolved !== "object") return {};
|
|
64
|
+
const out = { ...resolved };
|
|
65
|
+
for (const k of RESERVED_VAR_KEYS) delete out[k];
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
var RESERVED_VAR_KEYS;
|
|
69
|
+
var init_vars = __esm({
|
|
70
|
+
"src/server/adapters/vars.ts"() {
|
|
71
|
+
RESERVED_VAR_KEYS = [
|
|
72
|
+
"contact",
|
|
73
|
+
"vars",
|
|
74
|
+
"event",
|
|
75
|
+
"unsubscribeUrl",
|
|
76
|
+
"viewInBrowserUrl",
|
|
77
|
+
"preferenceCenterUrl",
|
|
78
|
+
"senderAddress"
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
34
83
|
// src/server/adapters/mongo.ts
|
|
35
84
|
var mongo_exports = {};
|
|
36
85
|
__export(mongo_exports, {
|
|
@@ -344,6 +393,12 @@ var tagInputSchema = z.object({
|
|
|
344
393
|
externalId: externalIdSchema,
|
|
345
394
|
tag: z.string().min(1).max(128)
|
|
346
395
|
});
|
|
396
|
+
var abortFlowInputSchema = z.object({
|
|
397
|
+
flowSlug: slugSchema,
|
|
398
|
+
externalId: externalIdSchema,
|
|
399
|
+
reason: z.string().min(1).max(200).optional()
|
|
400
|
+
});
|
|
401
|
+
var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
|
|
347
402
|
var sendOneOffInputSchema = z.object({
|
|
348
403
|
templateSlug: slugSchema,
|
|
349
404
|
externalId: externalIdSchema,
|
|
@@ -373,7 +428,13 @@ var flowStepSchema = z.lazy(
|
|
|
373
428
|
type: z.literal("send"),
|
|
374
429
|
templateSlug: slugSchema,
|
|
375
430
|
providerOverride: z.string().optional(),
|
|
376
|
-
vars: z.record(z.string(), z.unknown()).optional()
|
|
431
|
+
vars: z.record(z.string(), z.unknown()).optional(),
|
|
432
|
+
delivery: z.object({
|
|
433
|
+
weekdaysOnly: z.boolean().optional(),
|
|
434
|
+
timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
|
|
435
|
+
useContactTimezone: z.boolean().optional(),
|
|
436
|
+
timezone: z.string().optional()
|
|
437
|
+
}).optional()
|
|
377
438
|
}),
|
|
378
439
|
z.object({
|
|
379
440
|
type: z.literal("tag"),
|
|
@@ -1163,6 +1224,7 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1163
1224
|
flowSlug: flow.slug,
|
|
1164
1225
|
flowVersion: flow.version,
|
|
1165
1226
|
emailAtEntry: sub.emailAtSubscribe,
|
|
1227
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1166
1228
|
enteredAt: /* @__PURE__ */ new Date(),
|
|
1167
1229
|
status: "active",
|
|
1168
1230
|
currentStepIndex: 0,
|
|
@@ -1260,6 +1322,105 @@ function effectiveLowerBound(ctx, opts) {
|
|
|
1260
1322
|
}
|
|
1261
1323
|
return null;
|
|
1262
1324
|
}
|
|
1325
|
+
|
|
1326
|
+
// src/server/runner/delivery-window.ts
|
|
1327
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1328
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1329
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1330
|
+
let candidate = now;
|
|
1331
|
+
if (window.timeOfDay) {
|
|
1332
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1333
|
+
const local = localParts(candidate, tz);
|
|
1334
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1335
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1336
|
+
candidate = todaySlot;
|
|
1337
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1338
|
+
const next = addLocalDays(local, 1);
|
|
1339
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (window.weekdaysOnly) {
|
|
1343
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1344
|
+
const local = localParts(candidate, tz);
|
|
1345
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1346
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1347
|
+
const moved = addLocalDays(local, shift);
|
|
1348
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return candidate;
|
|
1352
|
+
}
|
|
1353
|
+
function pickTimezone(window, contactTimezone) {
|
|
1354
|
+
const candidates = [
|
|
1355
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1356
|
+
window.timezone,
|
|
1357
|
+
"UTC"
|
|
1358
|
+
];
|
|
1359
|
+
for (const tz of candidates) {
|
|
1360
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1361
|
+
}
|
|
1362
|
+
return "UTC";
|
|
1363
|
+
}
|
|
1364
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1365
|
+
function isValidTimezone(tz) {
|
|
1366
|
+
const cached = validatedZones.get(tz);
|
|
1367
|
+
if (cached !== void 0) return cached;
|
|
1368
|
+
let ok = true;
|
|
1369
|
+
try {
|
|
1370
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1371
|
+
} catch {
|
|
1372
|
+
ok = false;
|
|
1373
|
+
}
|
|
1374
|
+
validatedZones.set(tz, ok);
|
|
1375
|
+
return ok;
|
|
1376
|
+
}
|
|
1377
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1378
|
+
function formatterFor(tz) {
|
|
1379
|
+
let f = partFormatters.get(tz);
|
|
1380
|
+
if (!f) {
|
|
1381
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1382
|
+
timeZone: tz,
|
|
1383
|
+
year: "numeric",
|
|
1384
|
+
month: "2-digit",
|
|
1385
|
+
day: "2-digit",
|
|
1386
|
+
hour: "2-digit",
|
|
1387
|
+
minute: "2-digit",
|
|
1388
|
+
second: "2-digit",
|
|
1389
|
+
weekday: "short",
|
|
1390
|
+
hour12: false
|
|
1391
|
+
});
|
|
1392
|
+
partFormatters.set(tz, f);
|
|
1393
|
+
}
|
|
1394
|
+
return f;
|
|
1395
|
+
}
|
|
1396
|
+
function localParts(date, tz) {
|
|
1397
|
+
const parts = {};
|
|
1398
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1399
|
+
return {
|
|
1400
|
+
y: Number(parts.year),
|
|
1401
|
+
mo: Number(parts.month),
|
|
1402
|
+
d: Number(parts.day),
|
|
1403
|
+
hh: Number(parts.hour) % 24,
|
|
1404
|
+
// Intl emits '24' for midnight in some locales
|
|
1405
|
+
mi: Number(parts.minute),
|
|
1406
|
+
ss: Number(parts.second),
|
|
1407
|
+
weekday: parts.weekday
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
function utcFromLocal(y, mo, d, hh, mi, tz) {
|
|
1411
|
+
let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
|
1412
|
+
for (let i = 0; i < 2; i++) {
|
|
1413
|
+
const p = localParts(new Date(ts), tz);
|
|
1414
|
+
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
|
1415
|
+
const offset = asUtc - ts;
|
|
1416
|
+
ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
|
|
1417
|
+
}
|
|
1418
|
+
return new Date(ts);
|
|
1419
|
+
}
|
|
1420
|
+
function addLocalDays(p, days) {
|
|
1421
|
+
const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
|
|
1422
|
+
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
1423
|
+
}
|
|
1263
1424
|
async function compileTemplate(mjml) {
|
|
1264
1425
|
const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
|
|
1265
1426
|
const plainText = derivePlaintext(out.html);
|
|
@@ -1383,6 +1544,9 @@ function makeHandlebars(extra) {
|
|
|
1383
1544
|
return hb;
|
|
1384
1545
|
}
|
|
1385
1546
|
|
|
1547
|
+
// src/server/runner/send.ts
|
|
1548
|
+
init_vars();
|
|
1549
|
+
|
|
1386
1550
|
// src/server/runner/suppression.ts
|
|
1387
1551
|
var SCOPES_BY_KIND = {
|
|
1388
1552
|
marketing: ["all", "marketing"],
|
|
@@ -1701,13 +1865,29 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1701
1865
|
return;
|
|
1702
1866
|
}
|
|
1703
1867
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1868
|
+
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
1869
|
+
await ctx.collections.sends.updateOne(
|
|
1870
|
+
{ _id: send._id },
|
|
1871
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1872
|
+
);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
let renderCtx;
|
|
1876
|
+
let rendered;
|
|
1877
|
+
try {
|
|
1878
|
+
const resolved = await resolveVars(ctx.varsAdapter, contact, {
|
|
1879
|
+
reason: "send",
|
|
1880
|
+
templateSlug: template.slug,
|
|
1881
|
+
flowSlug: run?.flowSlug,
|
|
1882
|
+
eventName: run?.triggerEvent?.name,
|
|
1883
|
+
eventProperties: run?.triggerEvent?.properties
|
|
1884
|
+
});
|
|
1885
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
1886
|
+
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
1889
|
+
throw err;
|
|
1890
|
+
}
|
|
1711
1891
|
const tracking = applyTracking(rendered.html, {
|
|
1712
1892
|
sendId: String(send._id),
|
|
1713
1893
|
publicUrl: ctx.config.publicUrl,
|
|
@@ -1783,7 +1963,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
1783
1963
|
}
|
|
1784
1964
|
return ctx.config.defaultProvider;
|
|
1785
1965
|
}
|
|
1786
|
-
function buildRenderContext(contact, run, vars, ctx) {
|
|
1966
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
1787
1967
|
const scope = "marketing";
|
|
1788
1968
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
1789
1969
|
const token = signUnsubscribeToken(
|
|
@@ -1792,8 +1972,10 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
1792
1972
|
);
|
|
1793
1973
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
1794
1974
|
return {
|
|
1975
|
+
...resolved,
|
|
1795
1976
|
contact,
|
|
1796
1977
|
vars,
|
|
1978
|
+
event: run?.triggerEvent?.properties ?? {},
|
|
1797
1979
|
unsubscribeUrl,
|
|
1798
1980
|
senderAddress: ctx.config.senderAddress
|
|
1799
1981
|
};
|
|
@@ -1878,8 +2060,15 @@ async function processOneRunStep(runId, ctx) {
|
|
|
1878
2060
|
return handleCondition(run, step, contact, ctx);
|
|
1879
2061
|
case "branch":
|
|
1880
2062
|
return handleBranch(run, step, contact, ctx);
|
|
1881
|
-
case "send":
|
|
2063
|
+
case "send": {
|
|
2064
|
+
if (step.delivery) {
|
|
2065
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
|
|
2066
|
+
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
2067
|
+
return deferSendForWindow(run, deliverAt, ctx);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
1882
2070
|
return handleSend(run, step, contact, flow, ctx);
|
|
2071
|
+
}
|
|
1883
2072
|
case "tag":
|
|
1884
2073
|
return handleTag(run, step, ctx);
|
|
1885
2074
|
case "fire_event":
|
|
@@ -2030,6 +2219,34 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
2030
2219
|
}
|
|
2031
2220
|
}
|
|
2032
2221
|
}
|
|
2222
|
+
async function deferSendForWindow(run, deliverAt, ctx) {
|
|
2223
|
+
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2224
|
+
// Only write once per deferral — if nextActionAt already points at (or
|
|
2225
|
+
// past) the slot, another worker/tick got here first.
|
|
2226
|
+
{ _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
|
|
2227
|
+
{
|
|
2228
|
+
$set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
|
|
2229
|
+
$push: {
|
|
2230
|
+
history: {
|
|
2231
|
+
stepIndex: run.currentStepIndex,
|
|
2232
|
+
action: "send_deferred",
|
|
2233
|
+
at: /* @__PURE__ */ new Date(),
|
|
2234
|
+
details: { until: deliverAt }
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
},
|
|
2238
|
+
{ returnDocument: "after" }
|
|
2239
|
+
);
|
|
2240
|
+
if (!updated) return;
|
|
2241
|
+
await ctx.queues.advance.add(
|
|
2242
|
+
"advance",
|
|
2243
|
+
{ flowRunId: String(run._id) },
|
|
2244
|
+
{
|
|
2245
|
+
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2246
|
+
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2247
|
+
}
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2033
2250
|
async function advanceStep(run, ctx, log, opts = {}) {
|
|
2034
2251
|
const stepInc = opts.stepInc ?? 1;
|
|
2035
2252
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
@@ -3554,6 +3771,7 @@ var Mailer = class _Mailer {
|
|
|
3554
3771
|
db: this.db,
|
|
3555
3772
|
collections: this.collections,
|
|
3556
3773
|
adapter: this.adapter,
|
|
3774
|
+
varsAdapter: this.config.varsAdapter,
|
|
3557
3775
|
providers: this.providers,
|
|
3558
3776
|
queues: this.queues,
|
|
3559
3777
|
config: this.config,
|
|
@@ -3633,6 +3851,10 @@ var Mailer = class _Mailer {
|
|
|
3633
3851
|
if (!config.providers[config.defaultProvider]) {
|
|
3634
3852
|
throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
|
|
3635
3853
|
}
|
|
3854
|
+
if (config.varsAdapter) {
|
|
3855
|
+
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
3856
|
+
assertNoReservedVarKeys2(config.varsAdapter);
|
|
3857
|
+
}
|
|
3636
3858
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
3637
3859
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
3638
3860
|
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
@@ -3848,6 +4070,72 @@ var Mailer = class _Mailer {
|
|
|
3848
4070
|
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
3849
4071
|
}
|
|
3850
4072
|
}
|
|
4073
|
+
// -------------------------------------------------------------------------
|
|
4074
|
+
// Flow abort
|
|
4075
|
+
// -------------------------------------------------------------------------
|
|
4076
|
+
/**
|
|
4077
|
+
* Abort every active run of one flow for a contact, immediately. Runs parked
|
|
4078
|
+
* in a `wait` exit too — their delayed wake-up jobs find the run exited and
|
|
4079
|
+
* no-op. Also cancels any of the flow's emails still sitting in the send
|
|
4080
|
+
* queue for this contact (queued or awaiting retry), so an abort means no
|
|
4081
|
+
* further mail, not just no further steps.
|
|
4082
|
+
*
|
|
4083
|
+
* No-op (returns zero counts) when nothing is active. Safe to call from the
|
|
4084
|
+
* same handler that processes the business event ("user upgraded").
|
|
4085
|
+
*/
|
|
4086
|
+
async abortFlow(flowSlug, externalId, opts = {}) {
|
|
4087
|
+
const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
|
|
4088
|
+
const flow = await this.collections.flows.findOne(
|
|
4089
|
+
{ slug: parsed.flowSlug },
|
|
4090
|
+
{ projection: { _id: 1 } }
|
|
4091
|
+
);
|
|
4092
|
+
if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
|
|
4093
|
+
const result = await this.abortActiveRuns(
|
|
4094
|
+
{ externalId: parsed.externalId, flowId: flow._id },
|
|
4095
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
4096
|
+
);
|
|
4097
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
4098
|
+
await this.audit({
|
|
4099
|
+
actor: "host",
|
|
4100
|
+
action: "flow.abort",
|
|
4101
|
+
resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
|
|
4102
|
+
diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
return result;
|
|
4106
|
+
}
|
|
4107
|
+
/**
|
|
4108
|
+
* Abort every active flow run for a contact across all flows. Same semantics
|
|
4109
|
+
* as `abortFlow` — for "stop everything" events (account deleted, churned).
|
|
4110
|
+
*/
|
|
4111
|
+
async abortAllFlows(externalId, opts = {}) {
|
|
4112
|
+
const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
|
|
4113
|
+
const result = await this.abortActiveRuns(
|
|
4114
|
+
{ externalId: parsed.externalId },
|
|
4115
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
4116
|
+
);
|
|
4117
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
4118
|
+
await this.audit({
|
|
4119
|
+
actor: "host",
|
|
4120
|
+
action: "flow.abort_all",
|
|
4121
|
+
resource: { collection: "mailer_flow_runs" },
|
|
4122
|
+
diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
4123
|
+
});
|
|
4124
|
+
}
|
|
4125
|
+
return result;
|
|
4126
|
+
}
|
|
4127
|
+
async abortActiveRuns(filter, exitReason) {
|
|
4128
|
+
const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
|
|
4129
|
+
if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
|
|
4130
|
+
for (const run of runs) {
|
|
4131
|
+
await exitFlowRun(run, exitReason, this.runnerContext);
|
|
4132
|
+
}
|
|
4133
|
+
const cancelled = await this.collections.sends.updateMany(
|
|
4134
|
+
{ flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
|
|
4135
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
4136
|
+
);
|
|
4137
|
+
return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
|
|
4138
|
+
}
|
|
3851
4139
|
/**
|
|
3852
4140
|
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
3853
4141
|
* suppression row to block re-import. INVARIANT 9.
|
|
@@ -4050,6 +4338,7 @@ var Mailer = class _Mailer {
|
|
|
4050
4338
|
|
|
4051
4339
|
// src/server/index.ts
|
|
4052
4340
|
init_mongo();
|
|
4341
|
+
init_vars();
|
|
4053
4342
|
|
|
4054
4343
|
// src/server/providers/null.ts
|
|
4055
4344
|
var counter = 0;
|
|
@@ -4188,6 +4477,18 @@ ${input.plainText}`);
|
|
|
4188
4477
|
hint: "Front-load the important words so the truncated preview still makes sense."
|
|
4189
4478
|
});
|
|
4190
4479
|
}
|
|
4480
|
+
if (config.varsJsonSchema) {
|
|
4481
|
+
const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
|
|
4482
|
+
const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
|
|
4483
|
+
if (unknown.length > 0) {
|
|
4484
|
+
issues.push({
|
|
4485
|
+
rule: "unknown_variable",
|
|
4486
|
+
severity: "warning",
|
|
4487
|
+
message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
|
|
4488
|
+
hint: "These render as empty strings. Check for typos, or add the key to your varsAdapter schema. Paths inside {{#each}}/{{#with}} blocks are relative and not checked."
|
|
4489
|
+
});
|
|
4490
|
+
}
|
|
4491
|
+
}
|
|
4191
4492
|
const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
|
|
4192
4493
|
if (linkCount > 10) {
|
|
4193
4494
|
issues.push({
|
|
@@ -4250,6 +4551,60 @@ function findSpamSignals(text) {
|
|
|
4250
4551
|
if (/!{3,}/.test(text)) out.add('excessive "!!!"');
|
|
4251
4552
|
return Array.from(out);
|
|
4252
4553
|
}
|
|
4554
|
+
var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
|
|
4555
|
+
"contact",
|
|
4556
|
+
"vars",
|
|
4557
|
+
"event",
|
|
4558
|
+
"unsubscribeUrl",
|
|
4559
|
+
"viewInBrowserUrl",
|
|
4560
|
+
"preferenceCenterUrl",
|
|
4561
|
+
"senderAddress",
|
|
4562
|
+
"this"
|
|
4563
|
+
]);
|
|
4564
|
+
var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
|
|
4565
|
+
function findUnknownVariables(source, schema) {
|
|
4566
|
+
const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
|
|
4567
|
+
const unknown = /* @__PURE__ */ new Set();
|
|
4568
|
+
const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
|
|
4569
|
+
let m;
|
|
4570
|
+
while (m = re.exec(source)) {
|
|
4571
|
+
const expr = m[1].trim();
|
|
4572
|
+
if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
|
|
4573
|
+
if (expr.includes("(")) continue;
|
|
4574
|
+
const parts = expr.split(/\s+/);
|
|
4575
|
+
const candidates = parts.length > 1 ? parts.slice(1) : parts;
|
|
4576
|
+
for (const raw of candidates) {
|
|
4577
|
+
if (!PATH_TOKEN.test(raw)) continue;
|
|
4578
|
+
if (raw.startsWith("@")) continue;
|
|
4579
|
+
const segments = raw.split(".");
|
|
4580
|
+
if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
|
|
4581
|
+
if (hasBlockScopes && segments.length === 1) continue;
|
|
4582
|
+
if (!schemaHasPath(schema, segments)) unknown.add(raw);
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
return Array.from(unknown);
|
|
4586
|
+
}
|
|
4587
|
+
function schemaHasPath(node, segments) {
|
|
4588
|
+
if (segments.length === 0) return true;
|
|
4589
|
+
if (!node || typeof node !== "object") return true;
|
|
4590
|
+
const n = node;
|
|
4591
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
4592
|
+
if (Array.isArray(n[key])) {
|
|
4593
|
+
return n[key].some((branch) => schemaHasPath(branch, segments));
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
|
|
4597
|
+
if (n.type === "array") {
|
|
4598
|
+
if (segments[0] === "length") return true;
|
|
4599
|
+
return schemaHasPath(n.items, segments);
|
|
4600
|
+
}
|
|
4601
|
+
const props = n.properties;
|
|
4602
|
+
if (!props || typeof props !== "object") return true;
|
|
4603
|
+
const [head, ...rest] = segments;
|
|
4604
|
+
if (head in props) return schemaHasPath(props[head], rest);
|
|
4605
|
+
if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
|
|
4606
|
+
return true;
|
|
4607
|
+
}
|
|
4253
4608
|
function isAllCaps(subject) {
|
|
4254
4609
|
const letters = subject.replace(/[^a-zA-Z]/g, "");
|
|
4255
4610
|
if (letters.length < 5) return false;
|
|
@@ -4257,6 +4612,9 @@ function isAllCaps(subject) {
|
|
|
4257
4612
|
return upper / letters.length > 0.5;
|
|
4258
4613
|
}
|
|
4259
4614
|
|
|
4615
|
+
// src/server/api/admin.ts
|
|
4616
|
+
init_vars();
|
|
4617
|
+
|
|
4260
4618
|
// src/server/api/setup-status.ts
|
|
4261
4619
|
async function runSetupChecks(mailer) {
|
|
4262
4620
|
const checks = [];
|
|
@@ -5036,12 +5394,19 @@ function createAdminRouter(mailer, opts = {}) {
|
|
|
5036
5394
|
function apiRouter(mailer, opts = {}) {
|
|
5037
5395
|
const r = Router();
|
|
5038
5396
|
const c = mailer.collections;
|
|
5397
|
+
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
5039
5398
|
function getMailTesterClient() {
|
|
5040
5399
|
if (opts.mailTesterClient) return opts.mailTesterClient;
|
|
5041
5400
|
const cfg = mailer.config.mailTester;
|
|
5042
5401
|
if (!cfg?.apiKey) return null;
|
|
5043
5402
|
return createMailTesterClient(cfg);
|
|
5044
5403
|
}
|
|
5404
|
+
r.get(
|
|
5405
|
+
"/vars-schema",
|
|
5406
|
+
asyncHandler(async (_req, res) => {
|
|
5407
|
+
res.json({ schema: varsSchema, builtins: RESERVED_VAR_KEYS });
|
|
5408
|
+
})
|
|
5409
|
+
);
|
|
5045
5410
|
r.get(
|
|
5046
5411
|
"/me",
|
|
5047
5412
|
asyncHandler(async (req, res) => {
|
|
@@ -6128,9 +6493,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6128
6493
|
compileFailed: true
|
|
6129
6494
|
});
|
|
6130
6495
|
}
|
|
6496
|
+
if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
|
|
6497
|
+
if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
|
|
6131
6498
|
const lint = lintTemplate(
|
|
6132
6499
|
{ subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
|
|
6133
|
-
{ senderDomains: mailer.config.senderDomains }
|
|
6500
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
|
|
6134
6501
|
);
|
|
6135
6502
|
res.json({ ...lint, compileFailed: false });
|
|
6136
6503
|
})
|
|
@@ -6181,7 +6548,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6181
6548
|
kind: tpl.kind,
|
|
6182
6549
|
fromEmail: tpl.fromEmail
|
|
6183
6550
|
},
|
|
6184
|
-
{ senderDomains: mailer.config.senderDomains }
|
|
6551
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
|
|
6185
6552
|
);
|
|
6186
6553
|
if (lint.errors.length > 0) {
|
|
6187
6554
|
return res.status(422).json({
|
|
@@ -6273,40 +6640,93 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6273
6640
|
html = tpl.body.html;
|
|
6274
6641
|
plainText = tpl.body.plainText;
|
|
6275
6642
|
}
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6643
|
+
let contact;
|
|
6644
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
|
|
6645
|
+
if (contactId) {
|
|
6646
|
+
const found = await mailer.adapter.getById(contactId);
|
|
6647
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
|
|
6648
|
+
contact = found;
|
|
6649
|
+
} else {
|
|
6650
|
+
contact = req.body?.sampleContact ?? {
|
|
6651
|
+
externalId: "preview-contact",
|
|
6652
|
+
email: "preview@example.com",
|
|
6653
|
+
tags: [],
|
|
6654
|
+
fields: { firstName: "Alex" }
|
|
6655
|
+
};
|
|
6656
|
+
}
|
|
6657
|
+
const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
|
|
6658
|
+
let resolved = {};
|
|
6659
|
+
try {
|
|
6660
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
6661
|
+
reason: "preview",
|
|
6662
|
+
templateSlug: tpl.slug,
|
|
6663
|
+
eventProperties
|
|
6664
|
+
});
|
|
6665
|
+
} catch (err) {
|
|
6666
|
+
return res.status(502).json({
|
|
6667
|
+
error: "vars_resolve_failed",
|
|
6668
|
+
message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
|
|
6669
|
+
});
|
|
6670
|
+
}
|
|
6282
6671
|
const renderCtx = {
|
|
6283
|
-
|
|
6672
|
+
...resolved,
|
|
6673
|
+
contact,
|
|
6284
6674
|
vars: req.body?.vars ?? {},
|
|
6675
|
+
event: eventProperties ?? {},
|
|
6285
6676
|
unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
|
|
6286
6677
|
senderAddress: mailer.config.senderAddress
|
|
6287
6678
|
};
|
|
6288
6679
|
const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
|
|
6289
6680
|
const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
|
|
6290
|
-
return res.json({
|
|
6681
|
+
return res.json({
|
|
6682
|
+
subject: rendered.subject,
|
|
6683
|
+
preheader: rendered.preheader,
|
|
6684
|
+
html: rendered.html,
|
|
6685
|
+
plainText: rendered.plainText,
|
|
6686
|
+
contact: { externalId: contact.externalId, email: contact.email }
|
|
6687
|
+
});
|
|
6291
6688
|
})
|
|
6292
6689
|
);
|
|
6293
6690
|
r.post(
|
|
6294
6691
|
"/templates/:slug/send-test",
|
|
6295
6692
|
asyncHandler(async (req, res) => {
|
|
6296
|
-
const { to, sampleData } = req.body ?? {};
|
|
6693
|
+
const { to, sampleData, contactId, eventProperties } = req.body ?? {};
|
|
6297
6694
|
if (!to) return res.status(400).json({ error: "to_required" });
|
|
6298
6695
|
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
6299
6696
|
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
}
|
|
6306
|
-
|
|
6697
|
+
let contact;
|
|
6698
|
+
if (typeof contactId === "string" && contactId) {
|
|
6699
|
+
const found = await mailer.adapter.getById(contactId);
|
|
6700
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
|
|
6701
|
+
contact = { ...found, email: to };
|
|
6702
|
+
} else {
|
|
6703
|
+
contact = sampleData?.contact ?? {
|
|
6704
|
+
externalId: "test-recipient",
|
|
6705
|
+
email: to,
|
|
6706
|
+
tags: [],
|
|
6707
|
+
fields: { firstName: "Test" }
|
|
6708
|
+
};
|
|
6709
|
+
contact.email = to;
|
|
6710
|
+
}
|
|
6711
|
+
const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
|
|
6712
|
+
let resolved = {};
|
|
6713
|
+
try {
|
|
6714
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
6715
|
+
reason: "test",
|
|
6716
|
+
templateSlug: tpl.slug,
|
|
6717
|
+
eventProperties: evProps
|
|
6718
|
+
});
|
|
6719
|
+
} catch (err) {
|
|
6720
|
+
return res.status(502).json({
|
|
6721
|
+
error: "vars_resolve_failed",
|
|
6722
|
+
message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
|
|
6723
|
+
});
|
|
6724
|
+
}
|
|
6307
6725
|
const renderCtx = {
|
|
6726
|
+
...resolved,
|
|
6308
6727
|
contact,
|
|
6309
6728
|
vars: sampleData?.vars ?? {},
|
|
6729
|
+
event: evProps ?? {},
|
|
6310
6730
|
unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
|
|
6311
6731
|
senderAddress: mailer.config.senderAddress
|
|
6312
6732
|
};
|
|
@@ -6957,8 +7377,8 @@ var DEDUPE_POLICIES = [
|
|
|
6957
7377
|
];
|
|
6958
7378
|
|
|
6959
7379
|
// src/server/index.ts
|
|
6960
|
-
var VERSION = "0.
|
|
7380
|
+
var VERSION = "0.7.0";
|
|
6961
7381
|
|
|
6962
|
-
export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, verifyUnsubscribeToken };
|
|
7382
|
+
export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, varsJsonSchema, verifyUnsubscribeToken };
|
|
6963
7383
|
//# sourceMappingURL=index.js.map
|
|
6964
7384
|
//# sourceMappingURL=index.js.map
|