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.cjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var zod = require('zod');
|
|
3
4
|
var mongodb = require('mongodb');
|
|
4
5
|
var crypto2 = require('crypto');
|
|
5
6
|
var sgMail = require('@sendgrid/mail');
|
|
6
|
-
var zod = require('zod');
|
|
7
7
|
var IORedis = require('ioredis');
|
|
8
8
|
var Handlebars = require('handlebars');
|
|
9
9
|
var htmlToText = require('html-to-text');
|
|
@@ -49,6 +49,55 @@ var __export = (target, all) => {
|
|
|
49
49
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// src/server/adapters/vars.ts
|
|
53
|
+
var vars_exports = {};
|
|
54
|
+
__export(vars_exports, {
|
|
55
|
+
RESERVED_VAR_KEYS: () => exports.RESERVED_VAR_KEYS,
|
|
56
|
+
assertNoReservedVarKeys: () => assertNoReservedVarKeys,
|
|
57
|
+
defineVars: () => defineVars,
|
|
58
|
+
resolveVars: () => resolveVars,
|
|
59
|
+
varsJsonSchema: () => varsJsonSchema
|
|
60
|
+
});
|
|
61
|
+
function defineVars(adapter) {
|
|
62
|
+
return adapter;
|
|
63
|
+
}
|
|
64
|
+
function assertNoReservedVarKeys(adapter) {
|
|
65
|
+
const json = varsJsonSchema(adapter);
|
|
66
|
+
const props = json && typeof json === "object" ? json.properties : void 0;
|
|
67
|
+
if (!props) return;
|
|
68
|
+
const clashes = exports.RESERVED_VAR_KEYS.filter((k) => k in props);
|
|
69
|
+
if (clashes.length > 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function varsJsonSchema(adapter) {
|
|
76
|
+
return zod.z.toJSONSchema(adapter.schema, { io: "output" });
|
|
77
|
+
}
|
|
78
|
+
async function resolveVars(adapter, contact, info) {
|
|
79
|
+
if (!adapter) return {};
|
|
80
|
+
const resolved = await adapter.resolve(contact, info);
|
|
81
|
+
if (!resolved || typeof resolved !== "object") return {};
|
|
82
|
+
const out = { ...resolved };
|
|
83
|
+
for (const k of exports.RESERVED_VAR_KEYS) delete out[k];
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
exports.RESERVED_VAR_KEYS = void 0;
|
|
87
|
+
var init_vars = __esm({
|
|
88
|
+
"src/server/adapters/vars.ts"() {
|
|
89
|
+
exports.RESERVED_VAR_KEYS = [
|
|
90
|
+
"contact",
|
|
91
|
+
"vars",
|
|
92
|
+
"event",
|
|
93
|
+
"unsubscribeUrl",
|
|
94
|
+
"viewInBrowserUrl",
|
|
95
|
+
"preferenceCenterUrl",
|
|
96
|
+
"senderAddress"
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
52
101
|
// src/server/adapters/mongo.ts
|
|
53
102
|
var mongo_exports = {};
|
|
54
103
|
__export(mongo_exports, {
|
|
@@ -362,6 +411,12 @@ var tagInputSchema = zod.z.object({
|
|
|
362
411
|
externalId: externalIdSchema,
|
|
363
412
|
tag: zod.z.string().min(1).max(128)
|
|
364
413
|
});
|
|
414
|
+
var abortFlowInputSchema = zod.z.object({
|
|
415
|
+
flowSlug: slugSchema,
|
|
416
|
+
externalId: externalIdSchema,
|
|
417
|
+
reason: zod.z.string().min(1).max(200).optional()
|
|
418
|
+
});
|
|
419
|
+
var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
|
|
365
420
|
var sendOneOffInputSchema = zod.z.object({
|
|
366
421
|
templateSlug: slugSchema,
|
|
367
422
|
externalId: externalIdSchema,
|
|
@@ -391,7 +446,13 @@ var flowStepSchema = zod.z.lazy(
|
|
|
391
446
|
type: zod.z.literal("send"),
|
|
392
447
|
templateSlug: slugSchema,
|
|
393
448
|
providerOverride: zod.z.string().optional(),
|
|
394
|
-
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
449
|
+
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
|
|
450
|
+
delivery: zod.z.object({
|
|
451
|
+
weekdaysOnly: zod.z.boolean().optional(),
|
|
452
|
+
timeOfDay: zod.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
|
|
453
|
+
useContactTimezone: zod.z.boolean().optional(),
|
|
454
|
+
timezone: zod.z.string().optional()
|
|
455
|
+
}).optional()
|
|
395
456
|
}),
|
|
396
457
|
zod.z.object({
|
|
397
458
|
type: zod.z.literal("tag"),
|
|
@@ -1181,6 +1242,7 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1181
1242
|
flowSlug: flow.slug,
|
|
1182
1243
|
flowVersion: flow.version,
|
|
1183
1244
|
emailAtEntry: sub.emailAtSubscribe,
|
|
1245
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1184
1246
|
enteredAt: /* @__PURE__ */ new Date(),
|
|
1185
1247
|
status: "active",
|
|
1186
1248
|
currentStepIndex: 0,
|
|
@@ -1278,6 +1340,105 @@ function effectiveLowerBound(ctx, opts) {
|
|
|
1278
1340
|
}
|
|
1279
1341
|
return null;
|
|
1280
1342
|
}
|
|
1343
|
+
|
|
1344
|
+
// src/server/runner/delivery-window.ts
|
|
1345
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1346
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1347
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1348
|
+
let candidate = now;
|
|
1349
|
+
if (window.timeOfDay) {
|
|
1350
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1351
|
+
const local = localParts(candidate, tz);
|
|
1352
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1353
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1354
|
+
candidate = todaySlot;
|
|
1355
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1356
|
+
const next = addLocalDays(local, 1);
|
|
1357
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (window.weekdaysOnly) {
|
|
1361
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1362
|
+
const local = localParts(candidate, tz);
|
|
1363
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1364
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1365
|
+
const moved = addLocalDays(local, shift);
|
|
1366
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return candidate;
|
|
1370
|
+
}
|
|
1371
|
+
function pickTimezone(window, contactTimezone) {
|
|
1372
|
+
const candidates = [
|
|
1373
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1374
|
+
window.timezone,
|
|
1375
|
+
"UTC"
|
|
1376
|
+
];
|
|
1377
|
+
for (const tz of candidates) {
|
|
1378
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1379
|
+
}
|
|
1380
|
+
return "UTC";
|
|
1381
|
+
}
|
|
1382
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1383
|
+
function isValidTimezone(tz) {
|
|
1384
|
+
const cached = validatedZones.get(tz);
|
|
1385
|
+
if (cached !== void 0) return cached;
|
|
1386
|
+
let ok = true;
|
|
1387
|
+
try {
|
|
1388
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1389
|
+
} catch {
|
|
1390
|
+
ok = false;
|
|
1391
|
+
}
|
|
1392
|
+
validatedZones.set(tz, ok);
|
|
1393
|
+
return ok;
|
|
1394
|
+
}
|
|
1395
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1396
|
+
function formatterFor(tz) {
|
|
1397
|
+
let f = partFormatters.get(tz);
|
|
1398
|
+
if (!f) {
|
|
1399
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1400
|
+
timeZone: tz,
|
|
1401
|
+
year: "numeric",
|
|
1402
|
+
month: "2-digit",
|
|
1403
|
+
day: "2-digit",
|
|
1404
|
+
hour: "2-digit",
|
|
1405
|
+
minute: "2-digit",
|
|
1406
|
+
second: "2-digit",
|
|
1407
|
+
weekday: "short",
|
|
1408
|
+
hour12: false
|
|
1409
|
+
});
|
|
1410
|
+
partFormatters.set(tz, f);
|
|
1411
|
+
}
|
|
1412
|
+
return f;
|
|
1413
|
+
}
|
|
1414
|
+
function localParts(date, tz) {
|
|
1415
|
+
const parts = {};
|
|
1416
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1417
|
+
return {
|
|
1418
|
+
y: Number(parts.year),
|
|
1419
|
+
mo: Number(parts.month),
|
|
1420
|
+
d: Number(parts.day),
|
|
1421
|
+
hh: Number(parts.hour) % 24,
|
|
1422
|
+
// Intl emits '24' for midnight in some locales
|
|
1423
|
+
mi: Number(parts.minute),
|
|
1424
|
+
ss: Number(parts.second),
|
|
1425
|
+
weekday: parts.weekday
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
function utcFromLocal(y, mo, d, hh, mi, tz) {
|
|
1429
|
+
let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
|
1430
|
+
for (let i = 0; i < 2; i++) {
|
|
1431
|
+
const p = localParts(new Date(ts), tz);
|
|
1432
|
+
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
|
1433
|
+
const offset = asUtc - ts;
|
|
1434
|
+
ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
|
|
1435
|
+
}
|
|
1436
|
+
return new Date(ts);
|
|
1437
|
+
}
|
|
1438
|
+
function addLocalDays(p, days) {
|
|
1439
|
+
const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
|
|
1440
|
+
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
1441
|
+
}
|
|
1281
1442
|
async function compileTemplate(mjml) {
|
|
1282
1443
|
const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
|
|
1283
1444
|
const plainText = derivePlaintext(out.html);
|
|
@@ -1401,6 +1562,9 @@ function makeHandlebars(extra) {
|
|
|
1401
1562
|
return hb;
|
|
1402
1563
|
}
|
|
1403
1564
|
|
|
1565
|
+
// src/server/runner/send.ts
|
|
1566
|
+
init_vars();
|
|
1567
|
+
|
|
1404
1568
|
// src/server/runner/suppression.ts
|
|
1405
1569
|
var SCOPES_BY_KIND = {
|
|
1406
1570
|
marketing: ["all", "marketing"],
|
|
@@ -1719,13 +1883,29 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1719
1883
|
return;
|
|
1720
1884
|
}
|
|
1721
1885
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1886
|
+
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
1887
|
+
await ctx.collections.sends.updateOne(
|
|
1888
|
+
{ _id: send._id },
|
|
1889
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1890
|
+
);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
let renderCtx;
|
|
1894
|
+
let rendered;
|
|
1895
|
+
try {
|
|
1896
|
+
const resolved = await resolveVars(ctx.varsAdapter, contact, {
|
|
1897
|
+
reason: "send",
|
|
1898
|
+
templateSlug: template.slug,
|
|
1899
|
+
flowSlug: run?.flowSlug,
|
|
1900
|
+
eventName: run?.triggerEvent?.name,
|
|
1901
|
+
eventProperties: run?.triggerEvent?.properties
|
|
1902
|
+
});
|
|
1903
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
1904
|
+
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
1907
|
+
throw err;
|
|
1908
|
+
}
|
|
1729
1909
|
const tracking = applyTracking(rendered.html, {
|
|
1730
1910
|
sendId: String(send._id),
|
|
1731
1911
|
publicUrl: ctx.config.publicUrl,
|
|
@@ -1801,7 +1981,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
1801
1981
|
}
|
|
1802
1982
|
return ctx.config.defaultProvider;
|
|
1803
1983
|
}
|
|
1804
|
-
function buildRenderContext(contact, run, vars, ctx) {
|
|
1984
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
1805
1985
|
const scope = "marketing";
|
|
1806
1986
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
1807
1987
|
const token = signUnsubscribeToken(
|
|
@@ -1810,8 +1990,10 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
1810
1990
|
);
|
|
1811
1991
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
1812
1992
|
return {
|
|
1993
|
+
...resolved,
|
|
1813
1994
|
contact,
|
|
1814
1995
|
vars,
|
|
1996
|
+
event: run?.triggerEvent?.properties ?? {},
|
|
1815
1997
|
unsubscribeUrl,
|
|
1816
1998
|
senderAddress: ctx.config.senderAddress
|
|
1817
1999
|
};
|
|
@@ -1896,8 +2078,15 @@ async function processOneRunStep(runId, ctx) {
|
|
|
1896
2078
|
return handleCondition(run, step, contact, ctx);
|
|
1897
2079
|
case "branch":
|
|
1898
2080
|
return handleBranch(run, step, contact, ctx);
|
|
1899
|
-
case "send":
|
|
2081
|
+
case "send": {
|
|
2082
|
+
if (step.delivery) {
|
|
2083
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
|
|
2084
|
+
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
2085
|
+
return deferSendForWindow(run, deliverAt, ctx);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
1900
2088
|
return handleSend(run, step, contact, flow, ctx);
|
|
2089
|
+
}
|
|
1901
2090
|
case "tag":
|
|
1902
2091
|
return handleTag(run, step, ctx);
|
|
1903
2092
|
case "fire_event":
|
|
@@ -2048,6 +2237,34 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
2048
2237
|
}
|
|
2049
2238
|
}
|
|
2050
2239
|
}
|
|
2240
|
+
async function deferSendForWindow(run, deliverAt, ctx) {
|
|
2241
|
+
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2242
|
+
// Only write once per deferral — if nextActionAt already points at (or
|
|
2243
|
+
// past) the slot, another worker/tick got here first.
|
|
2244
|
+
{ _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
|
|
2245
|
+
{
|
|
2246
|
+
$set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
|
|
2247
|
+
$push: {
|
|
2248
|
+
history: {
|
|
2249
|
+
stepIndex: run.currentStepIndex,
|
|
2250
|
+
action: "send_deferred",
|
|
2251
|
+
at: /* @__PURE__ */ new Date(),
|
|
2252
|
+
details: { until: deliverAt }
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
},
|
|
2256
|
+
{ returnDocument: "after" }
|
|
2257
|
+
);
|
|
2258
|
+
if (!updated) return;
|
|
2259
|
+
await ctx.queues.advance.add(
|
|
2260
|
+
"advance",
|
|
2261
|
+
{ flowRunId: String(run._id) },
|
|
2262
|
+
{
|
|
2263
|
+
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2264
|
+
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2265
|
+
}
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2051
2268
|
async function advanceStep(run, ctx, log, opts = {}) {
|
|
2052
2269
|
const stepInc = opts.stepInc ?? 1;
|
|
2053
2270
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
@@ -3572,6 +3789,7 @@ var Mailer = class _Mailer {
|
|
|
3572
3789
|
db: this.db,
|
|
3573
3790
|
collections: this.collections,
|
|
3574
3791
|
adapter: this.adapter,
|
|
3792
|
+
varsAdapter: this.config.varsAdapter,
|
|
3575
3793
|
providers: this.providers,
|
|
3576
3794
|
queues: this.queues,
|
|
3577
3795
|
config: this.config,
|
|
@@ -3651,6 +3869,10 @@ var Mailer = class _Mailer {
|
|
|
3651
3869
|
if (!config.providers[config.defaultProvider]) {
|
|
3652
3870
|
throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
|
|
3653
3871
|
}
|
|
3872
|
+
if (config.varsAdapter) {
|
|
3873
|
+
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
3874
|
+
assertNoReservedVarKeys2(config.varsAdapter);
|
|
3875
|
+
}
|
|
3654
3876
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
3655
3877
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
3656
3878
|
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
@@ -3866,6 +4088,72 @@ var Mailer = class _Mailer {
|
|
|
3866
4088
|
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
3867
4089
|
}
|
|
3868
4090
|
}
|
|
4091
|
+
// -------------------------------------------------------------------------
|
|
4092
|
+
// Flow abort
|
|
4093
|
+
// -------------------------------------------------------------------------
|
|
4094
|
+
/**
|
|
4095
|
+
* Abort every active run of one flow for a contact, immediately. Runs parked
|
|
4096
|
+
* in a `wait` exit too — their delayed wake-up jobs find the run exited and
|
|
4097
|
+
* no-op. Also cancels any of the flow's emails still sitting in the send
|
|
4098
|
+
* queue for this contact (queued or awaiting retry), so an abort means no
|
|
4099
|
+
* further mail, not just no further steps.
|
|
4100
|
+
*
|
|
4101
|
+
* No-op (returns zero counts) when nothing is active. Safe to call from the
|
|
4102
|
+
* same handler that processes the business event ("user upgraded").
|
|
4103
|
+
*/
|
|
4104
|
+
async abortFlow(flowSlug, externalId, opts = {}) {
|
|
4105
|
+
const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
|
|
4106
|
+
const flow = await this.collections.flows.findOne(
|
|
4107
|
+
{ slug: parsed.flowSlug },
|
|
4108
|
+
{ projection: { _id: 1 } }
|
|
4109
|
+
);
|
|
4110
|
+
if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
|
|
4111
|
+
const result = await this.abortActiveRuns(
|
|
4112
|
+
{ externalId: parsed.externalId, flowId: flow._id },
|
|
4113
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
4114
|
+
);
|
|
4115
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
4116
|
+
await this.audit({
|
|
4117
|
+
actor: "host",
|
|
4118
|
+
action: "flow.abort",
|
|
4119
|
+
resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
|
|
4120
|
+
diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
4121
|
+
});
|
|
4122
|
+
}
|
|
4123
|
+
return result;
|
|
4124
|
+
}
|
|
4125
|
+
/**
|
|
4126
|
+
* Abort every active flow run for a contact across all flows. Same semantics
|
|
4127
|
+
* as `abortFlow` — for "stop everything" events (account deleted, churned).
|
|
4128
|
+
*/
|
|
4129
|
+
async abortAllFlows(externalId, opts = {}) {
|
|
4130
|
+
const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
|
|
4131
|
+
const result = await this.abortActiveRuns(
|
|
4132
|
+
{ externalId: parsed.externalId },
|
|
4133
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
4134
|
+
);
|
|
4135
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
4136
|
+
await this.audit({
|
|
4137
|
+
actor: "host",
|
|
4138
|
+
action: "flow.abort_all",
|
|
4139
|
+
resource: { collection: "mailer_flow_runs" },
|
|
4140
|
+
diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
4141
|
+
});
|
|
4142
|
+
}
|
|
4143
|
+
return result;
|
|
4144
|
+
}
|
|
4145
|
+
async abortActiveRuns(filter, exitReason) {
|
|
4146
|
+
const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
|
|
4147
|
+
if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
|
|
4148
|
+
for (const run of runs) {
|
|
4149
|
+
await exitFlowRun(run, exitReason, this.runnerContext);
|
|
4150
|
+
}
|
|
4151
|
+
const cancelled = await this.collections.sends.updateMany(
|
|
4152
|
+
{ flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
|
|
4153
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
4154
|
+
);
|
|
4155
|
+
return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
|
|
4156
|
+
}
|
|
3869
4157
|
/**
|
|
3870
4158
|
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
3871
4159
|
* suppression row to block re-import. INVARIANT 9.
|
|
@@ -4068,6 +4356,7 @@ var Mailer = class _Mailer {
|
|
|
4068
4356
|
|
|
4069
4357
|
// src/server/index.ts
|
|
4070
4358
|
init_mongo();
|
|
4359
|
+
init_vars();
|
|
4071
4360
|
|
|
4072
4361
|
// src/server/providers/null.ts
|
|
4073
4362
|
var counter = 0;
|
|
@@ -4206,6 +4495,18 @@ ${input.plainText}`);
|
|
|
4206
4495
|
hint: "Front-load the important words so the truncated preview still makes sense."
|
|
4207
4496
|
});
|
|
4208
4497
|
}
|
|
4498
|
+
if (config.varsJsonSchema) {
|
|
4499
|
+
const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
|
|
4500
|
+
const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
|
|
4501
|
+
if (unknown.length > 0) {
|
|
4502
|
+
issues.push({
|
|
4503
|
+
rule: "unknown_variable",
|
|
4504
|
+
severity: "warning",
|
|
4505
|
+
message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
|
|
4506
|
+
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."
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4209
4510
|
const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
|
|
4210
4511
|
if (linkCount > 10) {
|
|
4211
4512
|
issues.push({
|
|
@@ -4268,6 +4569,60 @@ function findSpamSignals(text) {
|
|
|
4268
4569
|
if (/!{3,}/.test(text)) out.add('excessive "!!!"');
|
|
4269
4570
|
return Array.from(out);
|
|
4270
4571
|
}
|
|
4572
|
+
var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
|
|
4573
|
+
"contact",
|
|
4574
|
+
"vars",
|
|
4575
|
+
"event",
|
|
4576
|
+
"unsubscribeUrl",
|
|
4577
|
+
"viewInBrowserUrl",
|
|
4578
|
+
"preferenceCenterUrl",
|
|
4579
|
+
"senderAddress",
|
|
4580
|
+
"this"
|
|
4581
|
+
]);
|
|
4582
|
+
var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
|
|
4583
|
+
function findUnknownVariables(source, schema) {
|
|
4584
|
+
const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
|
|
4585
|
+
const unknown = /* @__PURE__ */ new Set();
|
|
4586
|
+
const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
|
|
4587
|
+
let m;
|
|
4588
|
+
while (m = re.exec(source)) {
|
|
4589
|
+
const expr = m[1].trim();
|
|
4590
|
+
if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
|
|
4591
|
+
if (expr.includes("(")) continue;
|
|
4592
|
+
const parts = expr.split(/\s+/);
|
|
4593
|
+
const candidates = parts.length > 1 ? parts.slice(1) : parts;
|
|
4594
|
+
for (const raw of candidates) {
|
|
4595
|
+
if (!PATH_TOKEN.test(raw)) continue;
|
|
4596
|
+
if (raw.startsWith("@")) continue;
|
|
4597
|
+
const segments = raw.split(".");
|
|
4598
|
+
if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
|
|
4599
|
+
if (hasBlockScopes && segments.length === 1) continue;
|
|
4600
|
+
if (!schemaHasPath(schema, segments)) unknown.add(raw);
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
return Array.from(unknown);
|
|
4604
|
+
}
|
|
4605
|
+
function schemaHasPath(node, segments) {
|
|
4606
|
+
if (segments.length === 0) return true;
|
|
4607
|
+
if (!node || typeof node !== "object") return true;
|
|
4608
|
+
const n = node;
|
|
4609
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
4610
|
+
if (Array.isArray(n[key])) {
|
|
4611
|
+
return n[key].some((branch) => schemaHasPath(branch, segments));
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
|
|
4615
|
+
if (n.type === "array") {
|
|
4616
|
+
if (segments[0] === "length") return true;
|
|
4617
|
+
return schemaHasPath(n.items, segments);
|
|
4618
|
+
}
|
|
4619
|
+
const props = n.properties;
|
|
4620
|
+
if (!props || typeof props !== "object") return true;
|
|
4621
|
+
const [head, ...rest] = segments;
|
|
4622
|
+
if (head in props) return schemaHasPath(props[head], rest);
|
|
4623
|
+
if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
|
|
4624
|
+
return true;
|
|
4625
|
+
}
|
|
4271
4626
|
function isAllCaps(subject) {
|
|
4272
4627
|
const letters = subject.replace(/[^a-zA-Z]/g, "");
|
|
4273
4628
|
if (letters.length < 5) return false;
|
|
@@ -4275,6 +4630,9 @@ function isAllCaps(subject) {
|
|
|
4275
4630
|
return upper / letters.length > 0.5;
|
|
4276
4631
|
}
|
|
4277
4632
|
|
|
4633
|
+
// src/server/api/admin.ts
|
|
4634
|
+
init_vars();
|
|
4635
|
+
|
|
4278
4636
|
// src/server/api/setup-status.ts
|
|
4279
4637
|
async function runSetupChecks(mailer) {
|
|
4280
4638
|
const checks = [];
|
|
@@ -5054,12 +5412,19 @@ function createAdminRouter(mailer, opts = {}) {
|
|
|
5054
5412
|
function apiRouter(mailer, opts = {}) {
|
|
5055
5413
|
const r = express.Router();
|
|
5056
5414
|
const c = mailer.collections;
|
|
5415
|
+
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
5057
5416
|
function getMailTesterClient() {
|
|
5058
5417
|
if (opts.mailTesterClient) return opts.mailTesterClient;
|
|
5059
5418
|
const cfg = mailer.config.mailTester;
|
|
5060
5419
|
if (!cfg?.apiKey) return null;
|
|
5061
5420
|
return createMailTesterClient(cfg);
|
|
5062
5421
|
}
|
|
5422
|
+
r.get(
|
|
5423
|
+
"/vars-schema",
|
|
5424
|
+
asyncHandler(async (_req, res) => {
|
|
5425
|
+
res.json({ schema: varsSchema, builtins: exports.RESERVED_VAR_KEYS });
|
|
5426
|
+
})
|
|
5427
|
+
);
|
|
5063
5428
|
r.get(
|
|
5064
5429
|
"/me",
|
|
5065
5430
|
asyncHandler(async (req, res) => {
|
|
@@ -6146,9 +6511,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6146
6511
|
compileFailed: true
|
|
6147
6512
|
});
|
|
6148
6513
|
}
|
|
6514
|
+
if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
|
|
6515
|
+
if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
|
|
6149
6516
|
const lint = lintTemplate(
|
|
6150
6517
|
{ subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
|
|
6151
|
-
{ senderDomains: mailer.config.senderDomains }
|
|
6518
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
|
|
6152
6519
|
);
|
|
6153
6520
|
res.json({ ...lint, compileFailed: false });
|
|
6154
6521
|
})
|
|
@@ -6199,7 +6566,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6199
6566
|
kind: tpl.kind,
|
|
6200
6567
|
fromEmail: tpl.fromEmail
|
|
6201
6568
|
},
|
|
6202
|
-
{ senderDomains: mailer.config.senderDomains }
|
|
6569
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
|
|
6203
6570
|
);
|
|
6204
6571
|
if (lint.errors.length > 0) {
|
|
6205
6572
|
return res.status(422).json({
|
|
@@ -6291,40 +6658,93 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6291
6658
|
html = tpl.body.html;
|
|
6292
6659
|
plainText = tpl.body.plainText;
|
|
6293
6660
|
}
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6661
|
+
let contact;
|
|
6662
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
|
|
6663
|
+
if (contactId) {
|
|
6664
|
+
const found = await mailer.adapter.getById(contactId);
|
|
6665
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
|
|
6666
|
+
contact = found;
|
|
6667
|
+
} else {
|
|
6668
|
+
contact = req.body?.sampleContact ?? {
|
|
6669
|
+
externalId: "preview-contact",
|
|
6670
|
+
email: "preview@example.com",
|
|
6671
|
+
tags: [],
|
|
6672
|
+
fields: { firstName: "Alex" }
|
|
6673
|
+
};
|
|
6674
|
+
}
|
|
6675
|
+
const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
|
|
6676
|
+
let resolved = {};
|
|
6677
|
+
try {
|
|
6678
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
6679
|
+
reason: "preview",
|
|
6680
|
+
templateSlug: tpl.slug,
|
|
6681
|
+
eventProperties
|
|
6682
|
+
});
|
|
6683
|
+
} catch (err) {
|
|
6684
|
+
return res.status(502).json({
|
|
6685
|
+
error: "vars_resolve_failed",
|
|
6686
|
+
message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
|
|
6687
|
+
});
|
|
6688
|
+
}
|
|
6300
6689
|
const renderCtx = {
|
|
6301
|
-
|
|
6690
|
+
...resolved,
|
|
6691
|
+
contact,
|
|
6302
6692
|
vars: req.body?.vars ?? {},
|
|
6693
|
+
event: eventProperties ?? {},
|
|
6303
6694
|
unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
|
|
6304
6695
|
senderAddress: mailer.config.senderAddress
|
|
6305
6696
|
};
|
|
6306
6697
|
const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
|
|
6307
6698
|
const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
|
|
6308
|
-
return res.json({
|
|
6699
|
+
return res.json({
|
|
6700
|
+
subject: rendered.subject,
|
|
6701
|
+
preheader: rendered.preheader,
|
|
6702
|
+
html: rendered.html,
|
|
6703
|
+
plainText: rendered.plainText,
|
|
6704
|
+
contact: { externalId: contact.externalId, email: contact.email }
|
|
6705
|
+
});
|
|
6309
6706
|
})
|
|
6310
6707
|
);
|
|
6311
6708
|
r.post(
|
|
6312
6709
|
"/templates/:slug/send-test",
|
|
6313
6710
|
asyncHandler(async (req, res) => {
|
|
6314
|
-
const { to, sampleData } = req.body ?? {};
|
|
6711
|
+
const { to, sampleData, contactId, eventProperties } = req.body ?? {};
|
|
6315
6712
|
if (!to) return res.status(400).json({ error: "to_required" });
|
|
6316
6713
|
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
6317
6714
|
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
}
|
|
6324
|
-
|
|
6715
|
+
let contact;
|
|
6716
|
+
if (typeof contactId === "string" && contactId) {
|
|
6717
|
+
const found = await mailer.adapter.getById(contactId);
|
|
6718
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
|
|
6719
|
+
contact = { ...found, email: to };
|
|
6720
|
+
} else {
|
|
6721
|
+
contact = sampleData?.contact ?? {
|
|
6722
|
+
externalId: "test-recipient",
|
|
6723
|
+
email: to,
|
|
6724
|
+
tags: [],
|
|
6725
|
+
fields: { firstName: "Test" }
|
|
6726
|
+
};
|
|
6727
|
+
contact.email = to;
|
|
6728
|
+
}
|
|
6729
|
+
const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
|
|
6730
|
+
let resolved = {};
|
|
6731
|
+
try {
|
|
6732
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
6733
|
+
reason: "test",
|
|
6734
|
+
templateSlug: tpl.slug,
|
|
6735
|
+
eventProperties: evProps
|
|
6736
|
+
});
|
|
6737
|
+
} catch (err) {
|
|
6738
|
+
return res.status(502).json({
|
|
6739
|
+
error: "vars_resolve_failed",
|
|
6740
|
+
message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
|
|
6741
|
+
});
|
|
6742
|
+
}
|
|
6325
6743
|
const renderCtx = {
|
|
6744
|
+
...resolved,
|
|
6326
6745
|
contact,
|
|
6327
6746
|
vars: sampleData?.vars ?? {},
|
|
6747
|
+
event: evProps ?? {},
|
|
6328
6748
|
unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
|
|
6329
6749
|
senderAddress: mailer.config.senderAddress
|
|
6330
6750
|
};
|
|
@@ -6975,7 +7395,7 @@ var DEDUPE_POLICIES = [
|
|
|
6975
7395
|
];
|
|
6976
7396
|
|
|
6977
7397
|
// src/server/index.ts
|
|
6978
|
-
var VERSION = "0.
|
|
7398
|
+
var VERSION = "0.7.0";
|
|
6979
7399
|
|
|
6980
7400
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
6981
7401
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|
|
@@ -6988,11 +7408,13 @@ exports.applyTracking = applyTracking;
|
|
|
6988
7408
|
exports.applyWebhookEvent = applyWebhookEvent;
|
|
6989
7409
|
exports.compileMailyTemplate = compileMailyTemplate;
|
|
6990
7410
|
exports.compileTemplate = compileTemplate;
|
|
7411
|
+
exports.computeDeliveryTime = computeDeliveryTime;
|
|
6991
7412
|
exports.createAdminRouter = createAdminRouter;
|
|
6992
7413
|
exports.createPublicRouter = createPublicRouter;
|
|
6993
7414
|
exports.defaultFlowStep = defaultFlowStep;
|
|
6994
7415
|
exports.defaultPredicate = defaultPredicate;
|
|
6995
7416
|
exports.defaultSegmentFilter = defaultSegmentFilter;
|
|
7417
|
+
exports.defineVars = defineVars;
|
|
6996
7418
|
exports.derivePlaintext = derivePlaintext;
|
|
6997
7419
|
exports.dispatchSend = dispatchSend;
|
|
6998
7420
|
exports.ensureIndexes = ensureIndexes;
|
|
@@ -7006,6 +7428,7 @@ exports.sha256Hex = sha256Hex;
|
|
|
7006
7428
|
exports.signUnsubscribeToken = signUnsubscribeToken;
|
|
7007
7429
|
exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
|
|
7008
7430
|
exports.validateSenderDomain = validateSenderDomain;
|
|
7431
|
+
exports.varsJsonSchema = varsJsonSchema;
|
|
7009
7432
|
exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
|
|
7010
7433
|
//# sourceMappingURL=index.cjs.map
|
|
7011
7434
|
//# sourceMappingURL=index.cjs.map
|