mailery 0.5.1 → 0.8.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-B3ndbezm.js} +8 -8
- package/dist/admin/spa/index-B3ndbezm.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-Bn28OPMU.js → template-editor-DwMKy5sw.js} +105 -105
- package/dist/admin/spa/template-editor-DwMKy5sw.js.map +1 -0
- package/dist/index.cjs +477 -40
- 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 +475 -41
- package/dist/index.js.map +1 -1
- package/dist/{null-BYB6v4Iv.d.cts → null-BCxlHRJ0.d.cts} +134 -3
- package/dist/{null-BYB6v4Iv.d.ts → null-BCxlHRJ0.d.ts} +134 -3
- package/dist/testing.cjs +634 -82
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +233 -13
- package/dist/testing.d.ts +233 -13
- package/dist/testing.js +628 -83
- package/dist/testing.js.map +1 -1
- package/package.json +4 -1
- package/dist/admin/spa/index-Dmsla2eL.js.map +0 -1
- package/dist/admin/spa/template-editor-Bn28OPMU.js.map +0 -1
package/dist/testing.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');
|
|
@@ -63,6 +63,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
63
63
|
));
|
|
64
64
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
65
65
|
|
|
66
|
+
// src/server/adapters/vars.ts
|
|
67
|
+
var vars_exports = {};
|
|
68
|
+
__export(vars_exports, {
|
|
69
|
+
RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
|
|
70
|
+
assertNoReservedVarKeys: () => assertNoReservedVarKeys,
|
|
71
|
+
defineVars: () => defineVars,
|
|
72
|
+
resolveVars: () => resolveVars,
|
|
73
|
+
varsJsonSchema: () => varsJsonSchema
|
|
74
|
+
});
|
|
75
|
+
function defineVars(adapter) {
|
|
76
|
+
return adapter;
|
|
77
|
+
}
|
|
78
|
+
function assertNoReservedVarKeys(adapter) {
|
|
79
|
+
const json = varsJsonSchema(adapter);
|
|
80
|
+
const props = json && typeof json === "object" ? json.properties : void 0;
|
|
81
|
+
if (!props) return;
|
|
82
|
+
const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
|
|
83
|
+
if (clashes.length > 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function varsJsonSchema(adapter) {
|
|
90
|
+
return zod.z.toJSONSchema(adapter.schema, { io: "output" });
|
|
91
|
+
}
|
|
92
|
+
async function resolveVars(adapter, contact, info) {
|
|
93
|
+
if (!adapter) return {};
|
|
94
|
+
const resolved = await adapter.resolve(contact, info);
|
|
95
|
+
if (!resolved || typeof resolved !== "object") return {};
|
|
96
|
+
const out = { ...resolved };
|
|
97
|
+
for (const k of RESERVED_VAR_KEYS) delete out[k];
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
var RESERVED_VAR_KEYS;
|
|
101
|
+
var init_vars = __esm({
|
|
102
|
+
"src/server/adapters/vars.ts"() {
|
|
103
|
+
RESERVED_VAR_KEYS = [
|
|
104
|
+
"contact",
|
|
105
|
+
"vars",
|
|
106
|
+
"event",
|
|
107
|
+
"unsubscribeUrl",
|
|
108
|
+
"viewInBrowserUrl",
|
|
109
|
+
"preferenceCenterUrl",
|
|
110
|
+
"senderAddress"
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
66
115
|
// src/server/adapters/mongo.ts
|
|
67
116
|
var mongo_exports = {};
|
|
68
117
|
__export(mongo_exports, {
|
|
@@ -455,22 +504,22 @@ function __awaiter(thisArg, _arguments, P, generator) {
|
|
|
455
504
|
return new (P || (P = Promise))(function(resolve, reject) {
|
|
456
505
|
function fulfilled(value) {
|
|
457
506
|
try {
|
|
458
|
-
|
|
507
|
+
step2(generator.next(value));
|
|
459
508
|
} catch (e) {
|
|
460
509
|
reject(e);
|
|
461
510
|
}
|
|
462
511
|
}
|
|
463
512
|
function rejected(value) {
|
|
464
513
|
try {
|
|
465
|
-
|
|
514
|
+
step2(generator["throw"](value));
|
|
466
515
|
} catch (e) {
|
|
467
516
|
reject(e);
|
|
468
517
|
}
|
|
469
518
|
}
|
|
470
|
-
function
|
|
519
|
+
function step2(result) {
|
|
471
520
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
|
472
521
|
}
|
|
473
|
-
|
|
522
|
+
step2((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
474
523
|
});
|
|
475
524
|
}
|
|
476
525
|
function __generator(thisArg, body) {
|
|
@@ -483,10 +532,10 @@ function __generator(thisArg, body) {
|
|
|
483
532
|
}), g;
|
|
484
533
|
function verb(n) {
|
|
485
534
|
return function(v) {
|
|
486
|
-
return
|
|
535
|
+
return step2([n, v]);
|
|
487
536
|
};
|
|
488
537
|
}
|
|
489
|
-
function
|
|
538
|
+
function step2(op) {
|
|
490
539
|
if (f) throw new TypeError("Generator is already executing.");
|
|
491
540
|
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
492
541
|
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
@@ -620,12 +669,12 @@ function __asyncGenerator(thisArg, _arguments, generator) {
|
|
|
620
669
|
}
|
|
621
670
|
function resume(n, v) {
|
|
622
671
|
try {
|
|
623
|
-
|
|
672
|
+
step2(g[n](v));
|
|
624
673
|
} catch (e) {
|
|
625
674
|
settle(q[0][3], e);
|
|
626
675
|
}
|
|
627
676
|
}
|
|
628
|
-
function
|
|
677
|
+
function step2(r) {
|
|
629
678
|
r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r);
|
|
630
679
|
}
|
|
631
680
|
function fulfill(value) {
|
|
@@ -3005,19 +3054,19 @@ var require_range = __commonJS({
|
|
|
3005
3054
|
var replaceCaret = (comp, options2) => {
|
|
3006
3055
|
debug("caret", comp, options2);
|
|
3007
3056
|
const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
|
|
3008
|
-
const
|
|
3057
|
+
const z3 = options2.includePrerelease ? "-0" : "";
|
|
3009
3058
|
return comp.replace(r, (_, M, m, p, pr) => {
|
|
3010
3059
|
debug("caret", comp, _, M, m, p, pr);
|
|
3011
3060
|
let ret;
|
|
3012
3061
|
if (isX(M)) {
|
|
3013
3062
|
ret = "";
|
|
3014
3063
|
} else if (isX(m)) {
|
|
3015
|
-
ret = `>=${M}.0.0${
|
|
3064
|
+
ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
|
|
3016
3065
|
} else if (isX(p)) {
|
|
3017
3066
|
if (M === "0") {
|
|
3018
|
-
ret = `>=${M}.${m}.0${
|
|
3067
|
+
ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
|
|
3019
3068
|
} else {
|
|
3020
|
-
ret = `>=${M}.${m}.0${
|
|
3069
|
+
ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
|
|
3021
3070
|
}
|
|
3022
3071
|
} else if (pr) {
|
|
3023
3072
|
debug("replaceCaret pr", pr);
|
|
@@ -3034,9 +3083,9 @@ var require_range = __commonJS({
|
|
|
3034
3083
|
debug("no pr");
|
|
3035
3084
|
if (M === "0") {
|
|
3036
3085
|
if (m === "0") {
|
|
3037
|
-
ret = `>=${M}.${m}.${p}${
|
|
3086
|
+
ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
|
|
3038
3087
|
} else {
|
|
3039
|
-
ret = `>=${M}.${m}.${p}${
|
|
3088
|
+
ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
|
|
3040
3089
|
}
|
|
3041
3090
|
} else {
|
|
3042
3091
|
ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
|
|
@@ -7043,9 +7092,9 @@ var require_pack = __commonJS({
|
|
|
7043
7092
|
this.push(headers.encode(newHeader));
|
|
7044
7093
|
}
|
|
7045
7094
|
_doDrain() {
|
|
7046
|
-
const
|
|
7095
|
+
const drain2 = this._drain;
|
|
7047
7096
|
this._drain = noop;
|
|
7048
|
-
|
|
7097
|
+
drain2();
|
|
7049
7098
|
}
|
|
7050
7099
|
_predestroy() {
|
|
7051
7100
|
const err = getStreamError(this);
|
|
@@ -14156,6 +14205,12 @@ var tagInputSchema = zod.z.object({
|
|
|
14156
14205
|
externalId: externalIdSchema,
|
|
14157
14206
|
tag: zod.z.string().min(1).max(128)
|
|
14158
14207
|
});
|
|
14208
|
+
var abortFlowInputSchema = zod.z.object({
|
|
14209
|
+
flowSlug: slugSchema,
|
|
14210
|
+
externalId: externalIdSchema,
|
|
14211
|
+
reason: zod.z.string().min(1).max(200).optional()
|
|
14212
|
+
});
|
|
14213
|
+
var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
|
|
14159
14214
|
var sendOneOffInputSchema = zod.z.object({
|
|
14160
14215
|
templateSlug: slugSchema,
|
|
14161
14216
|
externalId: externalIdSchema,
|
|
@@ -14185,7 +14240,13 @@ var flowStepSchema = zod.z.lazy(
|
|
|
14185
14240
|
type: zod.z.literal("send"),
|
|
14186
14241
|
templateSlug: slugSchema,
|
|
14187
14242
|
providerOverride: zod.z.string().optional(),
|
|
14188
|
-
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
14243
|
+
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
|
|
14244
|
+
delivery: zod.z.object({
|
|
14245
|
+
weekdaysOnly: zod.z.boolean().optional(),
|
|
14246
|
+
timeOfDay: zod.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
|
|
14247
|
+
useContactTimezone: zod.z.boolean().optional(),
|
|
14248
|
+
timezone: zod.z.string().optional()
|
|
14249
|
+
}).optional()
|
|
14189
14250
|
}),
|
|
14190
14251
|
zod.z.object({
|
|
14191
14252
|
type: zod.z.literal("tag"),
|
|
@@ -14920,6 +14981,7 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14920
14981
|
flowSlug: flow.slug,
|
|
14921
14982
|
flowVersion: flow.version,
|
|
14922
14983
|
emailAtEntry: sub.emailAtSubscribe,
|
|
14984
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
14923
14985
|
enteredAt: /* @__PURE__ */ new Date(),
|
|
14924
14986
|
status: "active",
|
|
14925
14987
|
currentStepIndex: 0,
|
|
@@ -15017,6 +15079,105 @@ function effectiveLowerBound(ctx, opts) {
|
|
|
15017
15079
|
}
|
|
15018
15080
|
return null;
|
|
15019
15081
|
}
|
|
15082
|
+
|
|
15083
|
+
// src/server/runner/delivery-window.ts
|
|
15084
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15085
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15086
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15087
|
+
let candidate = now;
|
|
15088
|
+
if (window2.timeOfDay) {
|
|
15089
|
+
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15090
|
+
const local = localParts(candidate, tz);
|
|
15091
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
15092
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
15093
|
+
candidate = todaySlot;
|
|
15094
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
15095
|
+
const next = addLocalDays(local, 1);
|
|
15096
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
15097
|
+
}
|
|
15098
|
+
}
|
|
15099
|
+
if (window2.weekdaysOnly) {
|
|
15100
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
15101
|
+
const local = localParts(candidate, tz);
|
|
15102
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
15103
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
15104
|
+
const moved = addLocalDays(local, shift);
|
|
15105
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
15106
|
+
}
|
|
15107
|
+
}
|
|
15108
|
+
return candidate;
|
|
15109
|
+
}
|
|
15110
|
+
function pickTimezone(window2, contactTimezone) {
|
|
15111
|
+
const candidates = [
|
|
15112
|
+
window2.useContactTimezone ? contactTimezone : void 0,
|
|
15113
|
+
window2.timezone,
|
|
15114
|
+
"UTC"
|
|
15115
|
+
];
|
|
15116
|
+
for (const tz of candidates) {
|
|
15117
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
15118
|
+
}
|
|
15119
|
+
return "UTC";
|
|
15120
|
+
}
|
|
15121
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
15122
|
+
function isValidTimezone(tz) {
|
|
15123
|
+
const cached = validatedZones.get(tz);
|
|
15124
|
+
if (cached !== void 0) return cached;
|
|
15125
|
+
let ok = true;
|
|
15126
|
+
try {
|
|
15127
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
15128
|
+
} catch {
|
|
15129
|
+
ok = false;
|
|
15130
|
+
}
|
|
15131
|
+
validatedZones.set(tz, ok);
|
|
15132
|
+
return ok;
|
|
15133
|
+
}
|
|
15134
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
15135
|
+
function formatterFor(tz) {
|
|
15136
|
+
let f = partFormatters.get(tz);
|
|
15137
|
+
if (!f) {
|
|
15138
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
15139
|
+
timeZone: tz,
|
|
15140
|
+
year: "numeric",
|
|
15141
|
+
month: "2-digit",
|
|
15142
|
+
day: "2-digit",
|
|
15143
|
+
hour: "2-digit",
|
|
15144
|
+
minute: "2-digit",
|
|
15145
|
+
second: "2-digit",
|
|
15146
|
+
weekday: "short",
|
|
15147
|
+
hour12: false
|
|
15148
|
+
});
|
|
15149
|
+
partFormatters.set(tz, f);
|
|
15150
|
+
}
|
|
15151
|
+
return f;
|
|
15152
|
+
}
|
|
15153
|
+
function localParts(date, tz) {
|
|
15154
|
+
const parts = {};
|
|
15155
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
15156
|
+
return {
|
|
15157
|
+
y: Number(parts.year),
|
|
15158
|
+
mo: Number(parts.month),
|
|
15159
|
+
d: Number(parts.day),
|
|
15160
|
+
hh: Number(parts.hour) % 24,
|
|
15161
|
+
// Intl emits '24' for midnight in some locales
|
|
15162
|
+
mi: Number(parts.minute),
|
|
15163
|
+
ss: Number(parts.second),
|
|
15164
|
+
weekday: parts.weekday
|
|
15165
|
+
};
|
|
15166
|
+
}
|
|
15167
|
+
function utcFromLocal(y, mo, d, hh, mi, tz) {
|
|
15168
|
+
let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
|
15169
|
+
for (let i = 0; i < 2; i++) {
|
|
15170
|
+
const p = localParts(new Date(ts), tz);
|
|
15171
|
+
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
|
15172
|
+
const offset = asUtc - ts;
|
|
15173
|
+
ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
|
|
15174
|
+
}
|
|
15175
|
+
return new Date(ts);
|
|
15176
|
+
}
|
|
15177
|
+
function addLocalDays(p, days) {
|
|
15178
|
+
const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
|
|
15179
|
+
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
15180
|
+
}
|
|
15020
15181
|
async function compileTemplate(mjml) {
|
|
15021
15182
|
const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
|
|
15022
15183
|
const plainText = derivePlaintext(out.html);
|
|
@@ -15134,6 +15295,9 @@ function makeHandlebars(extra) {
|
|
|
15134
15295
|
return hb;
|
|
15135
15296
|
}
|
|
15136
15297
|
|
|
15298
|
+
// src/server/runner/send.ts
|
|
15299
|
+
init_vars();
|
|
15300
|
+
|
|
15137
15301
|
// src/server/runner/suppression.ts
|
|
15138
15302
|
var SCOPES_BY_KIND = {
|
|
15139
15303
|
marketing: ["all", "marketing"],
|
|
@@ -15321,10 +15485,10 @@ async function getBucketStatus(ctx, fromEmail, kind) {
|
|
|
15321
15485
|
}
|
|
15322
15486
|
|
|
15323
15487
|
// src/server/runner/send.ts
|
|
15324
|
-
async function handleSend(run,
|
|
15325
|
-
const template = await ctx.collections.templates.findOne({ slug:
|
|
15488
|
+
async function handleSend(run, step2, contact, flow, ctx) {
|
|
15489
|
+
const template = await ctx.collections.templates.findOne({ slug: step2.templateSlug });
|
|
15326
15490
|
if (!template) {
|
|
15327
|
-
await failFlowRun(run, `template not found: ${
|
|
15491
|
+
await failFlowRun(run, `template not found: ${step2.templateSlug}`, ctx);
|
|
15328
15492
|
return;
|
|
15329
15493
|
}
|
|
15330
15494
|
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}`;
|
|
@@ -15336,8 +15500,8 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15336
15500
|
});
|
|
15337
15501
|
return;
|
|
15338
15502
|
}
|
|
15339
|
-
const providerName = pickProviderName(
|
|
15340
|
-
const renderCtx = buildRenderContext(contact, run,
|
|
15503
|
+
const providerName = pickProviderName(step2.providerOverride, template, ctx);
|
|
15504
|
+
const renderCtx = buildRenderContext(contact, run, step2.vars ?? {}, ctx);
|
|
15341
15505
|
let rendered;
|
|
15342
15506
|
try {
|
|
15343
15507
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
@@ -15374,7 +15538,7 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15374
15538
|
bodyHash: sha256(rendered.html),
|
|
15375
15539
|
status: "queued",
|
|
15376
15540
|
renderedFrom: rendered.fromName ? { name: rendered.fromName, email: rendered.fromEmail } : void 0,
|
|
15377
|
-
vars:
|
|
15541
|
+
vars: step2.vars ?? {}
|
|
15378
15542
|
})
|
|
15379
15543
|
);
|
|
15380
15544
|
await ctx.queues.send.add(
|
|
@@ -15414,13 +15578,29 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15414
15578
|
return;
|
|
15415
15579
|
}
|
|
15416
15580
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
15417
|
-
|
|
15418
|
-
|
|
15419
|
-
|
|
15420
|
-
|
|
15421
|
-
|
|
15422
|
-
|
|
15423
|
-
|
|
15581
|
+
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
15582
|
+
await ctx.collections.sends.updateOne(
|
|
15583
|
+
{ _id: send._id },
|
|
15584
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15585
|
+
);
|
|
15586
|
+
return;
|
|
15587
|
+
}
|
|
15588
|
+
let renderCtx;
|
|
15589
|
+
let rendered;
|
|
15590
|
+
try {
|
|
15591
|
+
const resolved = await resolveVars(ctx.varsAdapter, contact, {
|
|
15592
|
+
reason: "send",
|
|
15593
|
+
templateSlug: template.slug,
|
|
15594
|
+
flowSlug: run?.flowSlug,
|
|
15595
|
+
eventName: run?.triggerEvent?.name,
|
|
15596
|
+
eventProperties: run?.triggerEvent?.properties
|
|
15597
|
+
});
|
|
15598
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
15599
|
+
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
15600
|
+
} catch (err) {
|
|
15601
|
+
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
15602
|
+
throw err;
|
|
15603
|
+
}
|
|
15424
15604
|
const tracking = applyTracking(rendered.html, {
|
|
15425
15605
|
sendId: String(send._id),
|
|
15426
15606
|
publicUrl: ctx.config.publicUrl,
|
|
@@ -15447,6 +15627,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15447
15627
|
await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
|
|
15448
15628
|
return;
|
|
15449
15629
|
}
|
|
15630
|
+
const headers = send.kind === "marketing" ? {
|
|
15631
|
+
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15632
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15633
|
+
} : {};
|
|
15450
15634
|
try {
|
|
15451
15635
|
const result = await provider.send({
|
|
15452
15636
|
to: send.emailAtSend,
|
|
@@ -15456,10 +15640,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15456
15640
|
subject: rendered.subject,
|
|
15457
15641
|
html: tracking.html,
|
|
15458
15642
|
text: rendered.plainText,
|
|
15459
|
-
headers
|
|
15460
|
-
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15461
|
-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15462
|
-
},
|
|
15643
|
+
headers,
|
|
15463
15644
|
messageMeta: { sendId: String(send._id) }
|
|
15464
15645
|
});
|
|
15465
15646
|
await ctx.collections.sends.updateOne(
|
|
@@ -15496,7 +15677,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
15496
15677
|
}
|
|
15497
15678
|
return ctx.config.defaultProvider;
|
|
15498
15679
|
}
|
|
15499
|
-
function buildRenderContext(contact, run, vars, ctx) {
|
|
15680
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
15500
15681
|
const scope = "marketing";
|
|
15501
15682
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
15502
15683
|
const token = signUnsubscribeToken(
|
|
@@ -15505,8 +15686,10 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
15505
15686
|
);
|
|
15506
15687
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
15507
15688
|
return {
|
|
15689
|
+
...resolved,
|
|
15508
15690
|
contact,
|
|
15509
15691
|
vars,
|
|
15692
|
+
event: run?.triggerEvent?.properties ?? {},
|
|
15510
15693
|
unsubscribeUrl,
|
|
15511
15694
|
senderAddress: ctx.config.senderAddress
|
|
15512
15695
|
};
|
|
@@ -15569,8 +15752,8 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15569
15752
|
return;
|
|
15570
15753
|
}
|
|
15571
15754
|
const steps = await loadStepsForRun(run, flow, ctx);
|
|
15572
|
-
const
|
|
15573
|
-
if (!
|
|
15755
|
+
const step2 = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
|
|
15756
|
+
if (!step2) {
|
|
15574
15757
|
await completeFlowRun(run, "completed", ctx);
|
|
15575
15758
|
return;
|
|
15576
15759
|
}
|
|
@@ -15584,27 +15767,34 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15584
15767
|
await exitFlowRun(run, sub.status, ctx);
|
|
15585
15768
|
return;
|
|
15586
15769
|
}
|
|
15587
|
-
switch (
|
|
15770
|
+
switch (step2.type) {
|
|
15588
15771
|
case "wait":
|
|
15589
|
-
return handleWait(run,
|
|
15772
|
+
return handleWait(run, step2, ctx);
|
|
15590
15773
|
case "condition":
|
|
15591
|
-
return handleCondition(run,
|
|
15774
|
+
return handleCondition(run, step2, contact, ctx);
|
|
15592
15775
|
case "branch":
|
|
15593
|
-
return handleBranch(run,
|
|
15594
|
-
case "send":
|
|
15595
|
-
|
|
15776
|
+
return handleBranch(run, step2, contact, ctx);
|
|
15777
|
+
case "send": {
|
|
15778
|
+
if (step2.delivery) {
|
|
15779
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step2.delivery, contact.timezone);
|
|
15780
|
+
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
15781
|
+
return deferSendForWindow(run, deliverAt, ctx);
|
|
15782
|
+
}
|
|
15783
|
+
}
|
|
15784
|
+
return handleSend(run, step2, contact, flow, ctx);
|
|
15785
|
+
}
|
|
15596
15786
|
case "tag":
|
|
15597
|
-
return handleTag(run,
|
|
15787
|
+
return handleTag(run, step2, ctx);
|
|
15598
15788
|
case "fire_event":
|
|
15599
|
-
return handleFireEvent(run,
|
|
15789
|
+
return handleFireEvent(run, step2, ctx);
|
|
15600
15790
|
case "webhook":
|
|
15601
|
-
return handleWebhookStep(run,
|
|
15791
|
+
return handleWebhookStep(run, step2, ctx);
|
|
15602
15792
|
case "exit":
|
|
15603
|
-
return exitFlowRun(run,
|
|
15793
|
+
return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
|
|
15604
15794
|
}
|
|
15605
15795
|
}
|
|
15606
|
-
async function handleWait(run,
|
|
15607
|
-
const ms = unitToMs(
|
|
15796
|
+
async function handleWait(run, step2, ctx) {
|
|
15797
|
+
const ms = unitToMs(step2.value, step2.unit);
|
|
15608
15798
|
const nextAt = new Date(Date.now() + ms);
|
|
15609
15799
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15610
15800
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15629,22 +15819,22 @@ async function handleWait(run, step, ctx) {
|
|
|
15629
15819
|
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
15630
15820
|
);
|
|
15631
15821
|
}
|
|
15632
|
-
async function handleCondition(run,
|
|
15822
|
+
async function handleCondition(run, step2, contact, ctx) {
|
|
15633
15823
|
if (!contact) return;
|
|
15634
|
-
const result = await evaluatePredicate(
|
|
15824
|
+
const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
|
|
15635
15825
|
if (result) {
|
|
15636
15826
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
15637
15827
|
return;
|
|
15638
15828
|
}
|
|
15639
|
-
if (
|
|
15829
|
+
if (step2.ifFalse === "continue") {
|
|
15640
15830
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: false, skipped: 1 } }, { stepInc: 2 });
|
|
15641
15831
|
return;
|
|
15642
15832
|
}
|
|
15643
15833
|
await exitFlowRun(run, "condition_false", ctx);
|
|
15644
15834
|
}
|
|
15645
|
-
async function handleBranch(run,
|
|
15835
|
+
async function handleBranch(run, step2, contact, ctx) {
|
|
15646
15836
|
if (!contact) return;
|
|
15647
|
-
const result = await evaluatePredicate(
|
|
15837
|
+
const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
|
|
15648
15838
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
15649
15839
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15650
15840
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15670,9 +15860,9 @@ async function handleBranch(run, step, contact, ctx) {
|
|
|
15670
15860
|
if (!updated) return;
|
|
15671
15861
|
await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
|
|
15672
15862
|
}
|
|
15673
|
-
async function handleTag(run,
|
|
15674
|
-
const adds =
|
|
15675
|
-
const removes =
|
|
15863
|
+
async function handleTag(run, step2, ctx) {
|
|
15864
|
+
const adds = step2.addTags ?? [];
|
|
15865
|
+
const removes = step2.removeTags ?? [];
|
|
15676
15866
|
if (ctx.adapter.addTags && adds.length > 0) {
|
|
15677
15867
|
await ctx.adapter.addTags(run.externalId, adds);
|
|
15678
15868
|
} else if (adds.length > 0) {
|
|
@@ -15693,13 +15883,13 @@ async function handleTag(run, step, ctx) {
|
|
|
15693
15883
|
}
|
|
15694
15884
|
await advanceStep(run, ctx, { action: "tagged", details: { addTags: adds, removeTags: removes } });
|
|
15695
15885
|
}
|
|
15696
|
-
async function handleFireEvent(run,
|
|
15697
|
-
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${
|
|
15886
|
+
async function handleFireEvent(run, step2, ctx) {
|
|
15887
|
+
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step2.eventName}`;
|
|
15698
15888
|
try {
|
|
15699
15889
|
await ctx.collections.events.insertOne({
|
|
15700
15890
|
externalId: run.externalId,
|
|
15701
|
-
name:
|
|
15702
|
-
properties:
|
|
15891
|
+
name: step2.eventName,
|
|
15892
|
+
properties: step2.properties ?? {},
|
|
15703
15893
|
dedupeKey,
|
|
15704
15894
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
15705
15895
|
createdAt: /* @__PURE__ */ new Date()
|
|
@@ -15707,27 +15897,27 @@ async function handleFireEvent(run, step, ctx) {
|
|
|
15707
15897
|
} catch (err) {
|
|
15708
15898
|
if (err?.code !== 11e3) throw err;
|
|
15709
15899
|
}
|
|
15710
|
-
await advanceStep(run, ctx, { action: "event_fired", details: { name:
|
|
15900
|
+
await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
|
|
15711
15901
|
}
|
|
15712
|
-
async function handleWebhookStep(run,
|
|
15902
|
+
async function handleWebhookStep(run, step2, ctx) {
|
|
15713
15903
|
try {
|
|
15714
|
-
const res = await fetch(
|
|
15715
|
-
method:
|
|
15904
|
+
const res = await fetch(step2.url, {
|
|
15905
|
+
method: step2.method ?? "POST",
|
|
15716
15906
|
headers: { "Content-Type": "application/json" },
|
|
15717
|
-
body: JSON.stringify(
|
|
15907
|
+
body: JSON.stringify(step2.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
|
|
15718
15908
|
signal: AbortSignal.timeout(1e4)
|
|
15719
15909
|
});
|
|
15720
15910
|
if (!res.ok) throw new Error(`webhook step returned ${res.status}`);
|
|
15721
|
-
await advanceStep(run, ctx, { action: "webhook_called", details: { url:
|
|
15911
|
+
await advanceStep(run, ctx, { action: "webhook_called", details: { url: step2.url, status: res.status } });
|
|
15722
15912
|
} catch (err) {
|
|
15723
15913
|
const attempts = (run.attemptsForCurrentStep ?? 0) + 1;
|
|
15724
15914
|
if (attempts >= ctx.config.webhookRetryAttempts) {
|
|
15725
|
-
if (
|
|
15915
|
+
if (step2.failureMode === "fail_run") {
|
|
15726
15916
|
await failFlowRun(run, `webhook failed: ${err?.message}`, ctx);
|
|
15727
15917
|
} else {
|
|
15728
15918
|
await advanceStep(run, ctx, {
|
|
15729
15919
|
action: "webhook_called",
|
|
15730
|
-
details: { url:
|
|
15920
|
+
details: { url: step2.url, error: err?.message, exhausted: true }
|
|
15731
15921
|
});
|
|
15732
15922
|
}
|
|
15733
15923
|
} else {
|
|
@@ -15743,6 +15933,34 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
15743
15933
|
}
|
|
15744
15934
|
}
|
|
15745
15935
|
}
|
|
15936
|
+
async function deferSendForWindow(run, deliverAt, ctx) {
|
|
15937
|
+
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15938
|
+
// Only write once per deferral — if nextActionAt already points at (or
|
|
15939
|
+
// past) the slot, another worker/tick got here first.
|
|
15940
|
+
{ _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
|
|
15941
|
+
{
|
|
15942
|
+
$set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
|
|
15943
|
+
$push: {
|
|
15944
|
+
history: {
|
|
15945
|
+
stepIndex: run.currentStepIndex,
|
|
15946
|
+
action: "send_deferred",
|
|
15947
|
+
at: /* @__PURE__ */ new Date(),
|
|
15948
|
+
details: { until: deliverAt }
|
|
15949
|
+
}
|
|
15950
|
+
}
|
|
15951
|
+
},
|
|
15952
|
+
{ returnDocument: "after" }
|
|
15953
|
+
);
|
|
15954
|
+
if (!updated) return;
|
|
15955
|
+
await ctx.queues.advance.add(
|
|
15956
|
+
"advance",
|
|
15957
|
+
{ flowRunId: String(run._id) },
|
|
15958
|
+
{
|
|
15959
|
+
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15960
|
+
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15961
|
+
}
|
|
15962
|
+
);
|
|
15963
|
+
}
|
|
15746
15964
|
async function advanceStep(run, ctx, log, opts = {}) {
|
|
15747
15965
|
const stepInc = opts.stepInc ?? 1;
|
|
15748
15966
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
@@ -16995,6 +17213,7 @@ var Mailer = class _Mailer {
|
|
|
16995
17213
|
db: this.db,
|
|
16996
17214
|
collections: this.collections,
|
|
16997
17215
|
adapter: this.adapter,
|
|
17216
|
+
varsAdapter: this.config.varsAdapter,
|
|
16998
17217
|
providers: this.providers,
|
|
16999
17218
|
queues: this.queues,
|
|
17000
17219
|
config: this.config,
|
|
@@ -17074,6 +17293,10 @@ var Mailer = class _Mailer {
|
|
|
17074
17293
|
if (!config.providers[config.defaultProvider]) {
|
|
17075
17294
|
throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
|
|
17076
17295
|
}
|
|
17296
|
+
if (config.varsAdapter) {
|
|
17297
|
+
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
17298
|
+
assertNoReservedVarKeys2(config.varsAdapter);
|
|
17299
|
+
}
|
|
17077
17300
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
17078
17301
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
17079
17302
|
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
@@ -17289,6 +17512,72 @@ var Mailer = class _Mailer {
|
|
|
17289
17512
|
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
17290
17513
|
}
|
|
17291
17514
|
}
|
|
17515
|
+
// -------------------------------------------------------------------------
|
|
17516
|
+
// Flow abort
|
|
17517
|
+
// -------------------------------------------------------------------------
|
|
17518
|
+
/**
|
|
17519
|
+
* Abort every active run of one flow for a contact, immediately. Runs parked
|
|
17520
|
+
* in a `wait` exit too — their delayed wake-up jobs find the run exited and
|
|
17521
|
+
* no-op. Also cancels any of the flow's emails still sitting in the send
|
|
17522
|
+
* queue for this contact (queued or awaiting retry), so an abort means no
|
|
17523
|
+
* further mail, not just no further steps.
|
|
17524
|
+
*
|
|
17525
|
+
* No-op (returns zero counts) when nothing is active. Safe to call from the
|
|
17526
|
+
* same handler that processes the business event ("user upgraded").
|
|
17527
|
+
*/
|
|
17528
|
+
async abortFlow(flowSlug, externalId, opts = {}) {
|
|
17529
|
+
const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
|
|
17530
|
+
const flow = await this.collections.flows.findOne(
|
|
17531
|
+
{ slug: parsed.flowSlug },
|
|
17532
|
+
{ projection: { _id: 1 } }
|
|
17533
|
+
);
|
|
17534
|
+
if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
|
|
17535
|
+
const result = await this.abortActiveRuns(
|
|
17536
|
+
{ externalId: parsed.externalId, flowId: flow._id },
|
|
17537
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
17538
|
+
);
|
|
17539
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
17540
|
+
await this.audit({
|
|
17541
|
+
actor: "host",
|
|
17542
|
+
action: "flow.abort",
|
|
17543
|
+
resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
|
|
17544
|
+
diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
17545
|
+
});
|
|
17546
|
+
}
|
|
17547
|
+
return result;
|
|
17548
|
+
}
|
|
17549
|
+
/**
|
|
17550
|
+
* Abort every active flow run for a contact across all flows. Same semantics
|
|
17551
|
+
* as `abortFlow` — for "stop everything" events (account deleted, churned).
|
|
17552
|
+
*/
|
|
17553
|
+
async abortAllFlows(externalId, opts = {}) {
|
|
17554
|
+
const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
|
|
17555
|
+
const result = await this.abortActiveRuns(
|
|
17556
|
+
{ externalId: parsed.externalId },
|
|
17557
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
17558
|
+
);
|
|
17559
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
17560
|
+
await this.audit({
|
|
17561
|
+
actor: "host",
|
|
17562
|
+
action: "flow.abort_all",
|
|
17563
|
+
resource: { collection: "mailer_flow_runs" },
|
|
17564
|
+
diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
17565
|
+
});
|
|
17566
|
+
}
|
|
17567
|
+
return result;
|
|
17568
|
+
}
|
|
17569
|
+
async abortActiveRuns(filter, exitReason) {
|
|
17570
|
+
const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
|
|
17571
|
+
if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
|
|
17572
|
+
for (const run of runs) {
|
|
17573
|
+
await exitFlowRun(run, exitReason, this.runnerContext);
|
|
17574
|
+
}
|
|
17575
|
+
const cancelled = await this.collections.sends.updateMany(
|
|
17576
|
+
{ flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
|
|
17577
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
17578
|
+
);
|
|
17579
|
+
return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
|
|
17580
|
+
}
|
|
17292
17581
|
/**
|
|
17293
17582
|
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
17294
17583
|
* suppression row to block re-import. INVARIANT 9.
|
|
@@ -17564,6 +17853,207 @@ function matches(c, f) {
|
|
|
17564
17853
|
return true;
|
|
17565
17854
|
}
|
|
17566
17855
|
|
|
17856
|
+
// src/testing/recording-provider.ts
|
|
17857
|
+
var RecordingProvider = class {
|
|
17858
|
+
constructor(inner) {
|
|
17859
|
+
this.inner = inner;
|
|
17860
|
+
}
|
|
17861
|
+
inner;
|
|
17862
|
+
/** Every `SendArgs` handed to the provider, in call order. */
|
|
17863
|
+
sent = [];
|
|
17864
|
+
/** Same calls, with outcome attached. */
|
|
17865
|
+
records = [];
|
|
17866
|
+
get name() {
|
|
17867
|
+
return this.inner.name;
|
|
17868
|
+
}
|
|
17869
|
+
get sendRatePerSecond() {
|
|
17870
|
+
return this.inner.sendRatePerSecond;
|
|
17871
|
+
}
|
|
17872
|
+
async send(args) {
|
|
17873
|
+
this.sent.push(args);
|
|
17874
|
+
const record = { args, result: null, error: null, durationMs: 0, at: /* @__PURE__ */ new Date() };
|
|
17875
|
+
this.records.push(record);
|
|
17876
|
+
const startedAt = Date.now();
|
|
17877
|
+
try {
|
|
17878
|
+
const result = await this.inner.send(args);
|
|
17879
|
+
record.result = result;
|
|
17880
|
+
return result;
|
|
17881
|
+
} catch (err) {
|
|
17882
|
+
record.error = err instanceof Error ? err : new Error(String(err));
|
|
17883
|
+
throw err;
|
|
17884
|
+
} finally {
|
|
17885
|
+
record.durationMs = Date.now() - startedAt;
|
|
17886
|
+
}
|
|
17887
|
+
}
|
|
17888
|
+
async verifyWebhook(rawBody, headers) {
|
|
17889
|
+
return this.inner.verifyWebhook(rawBody, headers);
|
|
17890
|
+
}
|
|
17891
|
+
parseWebhookEvents(payload, headers) {
|
|
17892
|
+
return this.inner.parseWebhookEvents(payload, headers);
|
|
17893
|
+
}
|
|
17894
|
+
/** Last recorded send, or undefined. Sugar for the common single-send assertion. */
|
|
17895
|
+
get last() {
|
|
17896
|
+
return this.sent[this.sent.length - 1];
|
|
17897
|
+
}
|
|
17898
|
+
/** All sends addressed to `email` (case-insensitive). */
|
|
17899
|
+
toRecipient(email) {
|
|
17900
|
+
const lower = email.toLowerCase();
|
|
17901
|
+
return this.sent.filter((s) => s.to.toLowerCase() === lower);
|
|
17902
|
+
}
|
|
17903
|
+
reset() {
|
|
17904
|
+
this.sent.length = 0;
|
|
17905
|
+
this.records.length = 0;
|
|
17906
|
+
}
|
|
17907
|
+
};
|
|
17908
|
+
|
|
17909
|
+
// src/testing/builders.ts
|
|
17910
|
+
var BACKDATE_MS = 6e4;
|
|
17911
|
+
async function buildTemplate(spec) {
|
|
17912
|
+
const now = /* @__PURE__ */ new Date();
|
|
17913
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
17914
|
+
let mjml = "";
|
|
17915
|
+
let html;
|
|
17916
|
+
if (spec.html) {
|
|
17917
|
+
html = spec.html;
|
|
17918
|
+
} else {
|
|
17919
|
+
mjml = spec.mjml ?? wrapMjml(spec.text ?? "Hello {{contact.fields.firstName}}");
|
|
17920
|
+
const compiled = await compileTemplate(mjml);
|
|
17921
|
+
html = compiled.html;
|
|
17922
|
+
}
|
|
17923
|
+
const plainText = spec.plainText ?? derivePlaintext(html);
|
|
17924
|
+
return {
|
|
17925
|
+
slug: spec.slug,
|
|
17926
|
+
name: spec.name ?? spec.slug,
|
|
17927
|
+
description: spec.description ?? "",
|
|
17928
|
+
kind: spec.kind ?? "marketing",
|
|
17929
|
+
fromName: spec.fromName ?? "Test",
|
|
17930
|
+
fromEmail: spec.fromEmail ?? "hello@example.com",
|
|
17931
|
+
replyTo: spec.replyTo ?? null,
|
|
17932
|
+
providerOverride: spec.providerOverride ?? null,
|
|
17933
|
+
subject: spec.subject ?? "Test subject",
|
|
17934
|
+
preheader: spec.preheader ?? "",
|
|
17935
|
+
body: { mjml, editorJson: null, html, plainText, compiledAt: createdAt },
|
|
17936
|
+
variablesSchema: spec.variablesSchema ?? {},
|
|
17937
|
+
draft: null,
|
|
17938
|
+
tags: spec.tags ?? [],
|
|
17939
|
+
trackOpens: spec.trackOpens ?? false,
|
|
17940
|
+
trackClicks: spec.trackClicks ?? false,
|
|
17941
|
+
stats: {
|
|
17942
|
+
sent: 0,
|
|
17943
|
+
delivered: 0,
|
|
17944
|
+
opened: 0,
|
|
17945
|
+
clicked: 0,
|
|
17946
|
+
bounced: 0,
|
|
17947
|
+
complained: 0,
|
|
17948
|
+
unsubscribed: 0,
|
|
17949
|
+
lastSentAt: null
|
|
17950
|
+
},
|
|
17951
|
+
publishedAt: spec.published === false ? null : createdAt,
|
|
17952
|
+
publishedBy: spec.published === false ? null : "test",
|
|
17953
|
+
createdAt,
|
|
17954
|
+
updatedAt: createdAt
|
|
17955
|
+
};
|
|
17956
|
+
}
|
|
17957
|
+
function wrapMjml(body) {
|
|
17958
|
+
return `<mjml><mj-body><mj-section><mj-column><mj-text>${body}</mj-text></mj-column></mj-section></mj-body></mjml>`;
|
|
17959
|
+
}
|
|
17960
|
+
function buildFlow(spec) {
|
|
17961
|
+
const now = /* @__PURE__ */ new Date();
|
|
17962
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
17963
|
+
const trigger = spec.trigger ?? {
|
|
17964
|
+
type: "event",
|
|
17965
|
+
eventName: spec.eventName ?? `${spec.slug}:trigger`,
|
|
17966
|
+
once: spec.once ?? true
|
|
17967
|
+
};
|
|
17968
|
+
return {
|
|
17969
|
+
slug: spec.slug,
|
|
17970
|
+
name: spec.name ?? spec.slug,
|
|
17971
|
+
description: spec.description ?? "",
|
|
17972
|
+
trigger,
|
|
17973
|
+
enabled: spec.enabled ?? true,
|
|
17974
|
+
steps: spec.steps,
|
|
17975
|
+
version: spec.version ?? 1,
|
|
17976
|
+
draft: null,
|
|
17977
|
+
goal: spec.goal ?? "activation",
|
|
17978
|
+
audience: spec.audience ?? "test",
|
|
17979
|
+
expectedVolumePerWeek: null,
|
|
17980
|
+
stats: { activeRuns: 0, completedRuns: 0, sendsTotal: 0, sendsLast7Days: 0 },
|
|
17981
|
+
lastTriggerScanAt: null,
|
|
17982
|
+
publishedAt: createdAt,
|
|
17983
|
+
publishedBy: "test",
|
|
17984
|
+
createdAt,
|
|
17985
|
+
updatedAt: createdAt
|
|
17986
|
+
};
|
|
17987
|
+
}
|
|
17988
|
+
var step = {
|
|
17989
|
+
send(templateSlug, opts = {}) {
|
|
17990
|
+
return { type: "send", templateSlug, ...opts };
|
|
17991
|
+
},
|
|
17992
|
+
sendAt(templateSlug, delivery) {
|
|
17993
|
+
return { type: "send", templateSlug, delivery };
|
|
17994
|
+
},
|
|
17995
|
+
wait(value, unit = "minutes") {
|
|
17996
|
+
return { type: "wait", value, unit };
|
|
17997
|
+
},
|
|
17998
|
+
tag(addTags, removeTags) {
|
|
17999
|
+
return { type: "tag", addTags, removeTags };
|
|
18000
|
+
},
|
|
18001
|
+
exit(reason) {
|
|
18002
|
+
return { type: "exit", reason };
|
|
18003
|
+
},
|
|
18004
|
+
fireEvent(eventName, properties) {
|
|
18005
|
+
return { type: "fire_event", eventName, properties };
|
|
18006
|
+
}
|
|
18007
|
+
};
|
|
18008
|
+
|
|
18009
|
+
// src/testing/drive.ts
|
|
18010
|
+
async function drain(ctx, opts = {}) {
|
|
18011
|
+
const maxRounds = opts.maxRounds ?? 100;
|
|
18012
|
+
const shouldDispatch = opts.dispatch !== false;
|
|
18013
|
+
const attempted = /* @__PURE__ */ new Set();
|
|
18014
|
+
const errors = [];
|
|
18015
|
+
let dispatched = 0;
|
|
18016
|
+
let rounds = 0;
|
|
18017
|
+
while (rounds < maxRounds) {
|
|
18018
|
+
rounds++;
|
|
18019
|
+
await processNewlyFiredEventTriggers(ctx);
|
|
18020
|
+
await sweepStrandedFlowRuns(ctx);
|
|
18021
|
+
let didWork = false;
|
|
18022
|
+
if (shouldDispatch) {
|
|
18023
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18024
|
+
for (const row of queued) {
|
|
18025
|
+
const id = String(row._id);
|
|
18026
|
+
if (attempted.has(id)) continue;
|
|
18027
|
+
attempted.add(id);
|
|
18028
|
+
didWork = true;
|
|
18029
|
+
dispatched++;
|
|
18030
|
+
try {
|
|
18031
|
+
await dispatchSend(row._id, ctx);
|
|
18032
|
+
} catch (err) {
|
|
18033
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
18034
|
+
if (opts.throwOnSendError) throw err;
|
|
18035
|
+
}
|
|
18036
|
+
}
|
|
18037
|
+
}
|
|
18038
|
+
const due = await ctx.collections.flowRuns.countDocuments({
|
|
18039
|
+
status: "active",
|
|
18040
|
+
nextActionAt: { $lte: /* @__PURE__ */ new Date() }
|
|
18041
|
+
});
|
|
18042
|
+
if (!didWork && due === 0) {
|
|
18043
|
+
return { rounds, dispatched, errors, settled: true };
|
|
18044
|
+
}
|
|
18045
|
+
}
|
|
18046
|
+
return { rounds, dispatched, errors, settled: false };
|
|
18047
|
+
}
|
|
18048
|
+
async function dispatchQueued(ctx) {
|
|
18049
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18050
|
+
for (const row of queued) {
|
|
18051
|
+
await dispatchSend(row._id, ctx).catch(() => {
|
|
18052
|
+
});
|
|
18053
|
+
}
|
|
18054
|
+
return queued.length;
|
|
18055
|
+
}
|
|
18056
|
+
|
|
17567
18057
|
// src/testing/index.ts
|
|
17568
18058
|
async function createTestMailer(opts = {}) {
|
|
17569
18059
|
const { MongoMemoryServer } = await Promise.resolve().then(() => __toESM(require_mongodb_memory_server()));
|
|
@@ -17573,13 +18063,13 @@ async function createTestMailer(opts = {}) {
|
|
|
17573
18063
|
const db = client.db("mailery-test");
|
|
17574
18064
|
const memoryAdapter = opts.adapter ? null : new MemoryContactAdapter(opts.seedContacts ?? []);
|
|
17575
18065
|
const adapter = opts.adapter ?? memoryAdapter;
|
|
17576
|
-
const provider = opts.provider ??
|
|
18066
|
+
const provider = new RecordingProvider(await resolveProvider(opts.provider ?? "null"));
|
|
17577
18067
|
const mailer = await Mailer.init({
|
|
17578
18068
|
db,
|
|
17579
18069
|
adapter,
|
|
17580
|
-
queue: { driver: "noop" },
|
|
17581
|
-
providers: {
|
|
17582
|
-
defaultProvider:
|
|
18070
|
+
queue: opts.queue ?? { driver: "noop" },
|
|
18071
|
+
providers: { [provider.name]: provider },
|
|
18072
|
+
defaultProvider: provider.name,
|
|
17583
18073
|
publicUrl: "http://localhost:3000",
|
|
17584
18074
|
unsubscribeSecret: "test-unsub-secret-32-bytes-or-more-please",
|
|
17585
18075
|
senderAddress: "1 Test St, Brooklyn NY 11201",
|
|
@@ -17587,19 +18077,81 @@ async function createTestMailer(opts = {}) {
|
|
|
17587
18077
|
workerless: true,
|
|
17588
18078
|
...opts.config ?? {}
|
|
17589
18079
|
});
|
|
17590
|
-
|
|
17591
|
-
|
|
17592
|
-
|
|
17593
|
-
|
|
17594
|
-
|
|
17595
|
-
|
|
17596
|
-
|
|
18080
|
+
if (opts.startWorkers) await mailer.startWorkers();
|
|
18081
|
+
const ctx = mailer.getRunnerContext();
|
|
18082
|
+
const harness = {
|
|
18083
|
+
mailer,
|
|
18084
|
+
db,
|
|
18085
|
+
provider,
|
|
18086
|
+
adapter,
|
|
18087
|
+
memoryAdapter,
|
|
18088
|
+
ctx,
|
|
18089
|
+
async seedContact(contact, seedOpts = {}) {
|
|
18090
|
+
if (!memoryAdapter) {
|
|
18091
|
+
throw new Error("seedContact requires the built-in MemoryContactAdapter \u2014 you passed a custom `adapter`.");
|
|
18092
|
+
}
|
|
18093
|
+
memoryAdapter.upsert(contact);
|
|
18094
|
+
if (seedOpts.subscribe !== false) {
|
|
18095
|
+
await mailer.upsertSubscription({
|
|
18096
|
+
externalId: contact.externalId,
|
|
18097
|
+
source: seedOpts.source ?? "test"
|
|
18098
|
+
});
|
|
18099
|
+
}
|
|
18100
|
+
return contact;
|
|
18101
|
+
},
|
|
18102
|
+
async seedTemplate(spec) {
|
|
18103
|
+
const doc = await buildTemplate(spec);
|
|
18104
|
+
const res = await ctx.collections.templates.insertOne(doc);
|
|
18105
|
+
doc._id = res.insertedId;
|
|
18106
|
+
return doc;
|
|
18107
|
+
},
|
|
18108
|
+
async seedFlow(spec) {
|
|
18109
|
+
const doc = buildFlow(spec);
|
|
18110
|
+
const res = await ctx.collections.flows.insertOne(doc);
|
|
18111
|
+
doc._id = res.insertedId;
|
|
18112
|
+
return doc;
|
|
18113
|
+
},
|
|
18114
|
+
drain(drainOpts) {
|
|
18115
|
+
return drain(ctx, drainOpts);
|
|
18116
|
+
},
|
|
18117
|
+
stop: async () => {
|
|
18118
|
+
await mailer.stop().catch(() => {
|
|
18119
|
+
});
|
|
18120
|
+
await client.close().catch(() => {
|
|
18121
|
+
});
|
|
18122
|
+
await mongo.stop().catch(() => {
|
|
18123
|
+
});
|
|
18124
|
+
}
|
|
17597
18125
|
};
|
|
17598
|
-
return
|
|
18126
|
+
return harness;
|
|
18127
|
+
}
|
|
18128
|
+
async function resolveProvider(spec) {
|
|
18129
|
+
if (typeof spec !== "string") return spec;
|
|
18130
|
+
if (spec === "null") return new NullProvider();
|
|
18131
|
+
const apiKey = process.env.SENDGRID_API_KEY;
|
|
18132
|
+
if (!apiKey) {
|
|
18133
|
+
throw new Error(
|
|
18134
|
+
"createTestMailer({ provider: 'sendgrid' }) requires SENDGRID_API_KEY. Live tests should gate on it before constructing the harness."
|
|
18135
|
+
);
|
|
18136
|
+
}
|
|
18137
|
+
const { SendGridProvider: SendGridProvider2 } = await Promise.resolve().then(() => (init_sendgrid(), sendgrid_exports));
|
|
18138
|
+
return new SendGridProvider2({
|
|
18139
|
+
apiKey,
|
|
18140
|
+
// Sandbox unless explicitly told to deliver: SendGrid still authenticates
|
|
18141
|
+
// and validates the payload, it just never puts anything in an inbox.
|
|
18142
|
+
sandbox: process.env.MAILERY_LIVE_E2E !== "deliver"
|
|
18143
|
+
});
|
|
17599
18144
|
}
|
|
17600
18145
|
|
|
17601
18146
|
exports.MemoryContactAdapter = MemoryContactAdapter;
|
|
17602
18147
|
exports.NullProvider = NullProvider;
|
|
18148
|
+
exports.RecordingProvider = RecordingProvider;
|
|
18149
|
+
exports.buildFlow = buildFlow;
|
|
18150
|
+
exports.buildTemplate = buildTemplate;
|
|
17603
18151
|
exports.createTestMailer = createTestMailer;
|
|
18152
|
+
exports.dispatchQueued = dispatchQueued;
|
|
18153
|
+
exports.drain = drain;
|
|
18154
|
+
exports.step = step;
|
|
18155
|
+
exports.wrapMjml = wrapMjml;
|
|
17604
18156
|
//# sourceMappingURL=testing.cjs.map
|
|
17605
18157
|
//# sourceMappingURL=testing.cjs.map
|