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.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { MongoClient, 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';
|
|
@@ -50,6 +50,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
50
50
|
));
|
|
51
51
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
52
52
|
|
|
53
|
+
// src/server/adapters/vars.ts
|
|
54
|
+
var vars_exports = {};
|
|
55
|
+
__export(vars_exports, {
|
|
56
|
+
RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
|
|
57
|
+
assertNoReservedVarKeys: () => assertNoReservedVarKeys,
|
|
58
|
+
defineVars: () => defineVars,
|
|
59
|
+
resolveVars: () => resolveVars,
|
|
60
|
+
varsJsonSchema: () => varsJsonSchema
|
|
61
|
+
});
|
|
62
|
+
function defineVars(adapter) {
|
|
63
|
+
return adapter;
|
|
64
|
+
}
|
|
65
|
+
function assertNoReservedVarKeys(adapter) {
|
|
66
|
+
const json = varsJsonSchema(adapter);
|
|
67
|
+
const props = json && typeof json === "object" ? json.properties : void 0;
|
|
68
|
+
if (!props) return;
|
|
69
|
+
const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
|
|
70
|
+
if (clashes.length > 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function varsJsonSchema(adapter) {
|
|
77
|
+
return z.toJSONSchema(adapter.schema, { io: "output" });
|
|
78
|
+
}
|
|
79
|
+
async function resolveVars(adapter, contact, info) {
|
|
80
|
+
if (!adapter) return {};
|
|
81
|
+
const resolved = await adapter.resolve(contact, info);
|
|
82
|
+
if (!resolved || typeof resolved !== "object") return {};
|
|
83
|
+
const out = { ...resolved };
|
|
84
|
+
for (const k of RESERVED_VAR_KEYS) delete out[k];
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
var RESERVED_VAR_KEYS;
|
|
88
|
+
var init_vars = __esm({
|
|
89
|
+
"src/server/adapters/vars.ts"() {
|
|
90
|
+
RESERVED_VAR_KEYS = [
|
|
91
|
+
"contact",
|
|
92
|
+
"vars",
|
|
93
|
+
"event",
|
|
94
|
+
"unsubscribeUrl",
|
|
95
|
+
"viewInBrowserUrl",
|
|
96
|
+
"preferenceCenterUrl",
|
|
97
|
+
"senderAddress"
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
53
102
|
// src/server/adapters/mongo.ts
|
|
54
103
|
var mongo_exports = {};
|
|
55
104
|
__export(mongo_exports, {
|
|
@@ -442,22 +491,22 @@ function __awaiter(thisArg, _arguments, P, generator) {
|
|
|
442
491
|
return new (P || (P = Promise))(function(resolve, reject) {
|
|
443
492
|
function fulfilled(value) {
|
|
444
493
|
try {
|
|
445
|
-
|
|
494
|
+
step2(generator.next(value));
|
|
446
495
|
} catch (e) {
|
|
447
496
|
reject(e);
|
|
448
497
|
}
|
|
449
498
|
}
|
|
450
499
|
function rejected(value) {
|
|
451
500
|
try {
|
|
452
|
-
|
|
501
|
+
step2(generator["throw"](value));
|
|
453
502
|
} catch (e) {
|
|
454
503
|
reject(e);
|
|
455
504
|
}
|
|
456
505
|
}
|
|
457
|
-
function
|
|
506
|
+
function step2(result) {
|
|
458
507
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
|
459
508
|
}
|
|
460
|
-
|
|
509
|
+
step2((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
461
510
|
});
|
|
462
511
|
}
|
|
463
512
|
function __generator(thisArg, body) {
|
|
@@ -470,10 +519,10 @@ function __generator(thisArg, body) {
|
|
|
470
519
|
}), g;
|
|
471
520
|
function verb(n) {
|
|
472
521
|
return function(v) {
|
|
473
|
-
return
|
|
522
|
+
return step2([n, v]);
|
|
474
523
|
};
|
|
475
524
|
}
|
|
476
|
-
function
|
|
525
|
+
function step2(op) {
|
|
477
526
|
if (f) throw new TypeError("Generator is already executing.");
|
|
478
527
|
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
479
528
|
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;
|
|
@@ -607,12 +656,12 @@ function __asyncGenerator(thisArg, _arguments, generator) {
|
|
|
607
656
|
}
|
|
608
657
|
function resume(n, v) {
|
|
609
658
|
try {
|
|
610
|
-
|
|
659
|
+
step2(g[n](v));
|
|
611
660
|
} catch (e) {
|
|
612
661
|
settle(q[0][3], e);
|
|
613
662
|
}
|
|
614
663
|
}
|
|
615
|
-
function
|
|
664
|
+
function step2(r) {
|
|
616
665
|
r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r);
|
|
617
666
|
}
|
|
618
667
|
function fulfill(value) {
|
|
@@ -2992,19 +3041,19 @@ var require_range = __commonJS({
|
|
|
2992
3041
|
var replaceCaret = (comp, options2) => {
|
|
2993
3042
|
debug("caret", comp, options2);
|
|
2994
3043
|
const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
|
|
2995
|
-
const
|
|
3044
|
+
const z3 = options2.includePrerelease ? "-0" : "";
|
|
2996
3045
|
return comp.replace(r, (_, M, m, p, pr) => {
|
|
2997
3046
|
debug("caret", comp, _, M, m, p, pr);
|
|
2998
3047
|
let ret;
|
|
2999
3048
|
if (isX(M)) {
|
|
3000
3049
|
ret = "";
|
|
3001
3050
|
} else if (isX(m)) {
|
|
3002
|
-
ret = `>=${M}.0.0${
|
|
3051
|
+
ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
|
|
3003
3052
|
} else if (isX(p)) {
|
|
3004
3053
|
if (M === "0") {
|
|
3005
|
-
ret = `>=${M}.${m}.0${
|
|
3054
|
+
ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
|
|
3006
3055
|
} else {
|
|
3007
|
-
ret = `>=${M}.${m}.0${
|
|
3056
|
+
ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
|
|
3008
3057
|
}
|
|
3009
3058
|
} else if (pr) {
|
|
3010
3059
|
debug("replaceCaret pr", pr);
|
|
@@ -3021,9 +3070,9 @@ var require_range = __commonJS({
|
|
|
3021
3070
|
debug("no pr");
|
|
3022
3071
|
if (M === "0") {
|
|
3023
3072
|
if (m === "0") {
|
|
3024
|
-
ret = `>=${M}.${m}.${p}${
|
|
3073
|
+
ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
|
|
3025
3074
|
} else {
|
|
3026
|
-
ret = `>=${M}.${m}.${p}${
|
|
3075
|
+
ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
|
|
3027
3076
|
}
|
|
3028
3077
|
} else {
|
|
3029
3078
|
ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
|
|
@@ -7030,9 +7079,9 @@ var require_pack = __commonJS({
|
|
|
7030
7079
|
this.push(headers.encode(newHeader));
|
|
7031
7080
|
}
|
|
7032
7081
|
_doDrain() {
|
|
7033
|
-
const
|
|
7082
|
+
const drain2 = this._drain;
|
|
7034
7083
|
this._drain = noop;
|
|
7035
|
-
|
|
7084
|
+
drain2();
|
|
7036
7085
|
}
|
|
7037
7086
|
_predestroy() {
|
|
7038
7087
|
const err = getStreamError(this);
|
|
@@ -14143,6 +14192,12 @@ var tagInputSchema = z.object({
|
|
|
14143
14192
|
externalId: externalIdSchema,
|
|
14144
14193
|
tag: z.string().min(1).max(128)
|
|
14145
14194
|
});
|
|
14195
|
+
var abortFlowInputSchema = z.object({
|
|
14196
|
+
flowSlug: slugSchema,
|
|
14197
|
+
externalId: externalIdSchema,
|
|
14198
|
+
reason: z.string().min(1).max(200).optional()
|
|
14199
|
+
});
|
|
14200
|
+
var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
|
|
14146
14201
|
var sendOneOffInputSchema = z.object({
|
|
14147
14202
|
templateSlug: slugSchema,
|
|
14148
14203
|
externalId: externalIdSchema,
|
|
@@ -14172,7 +14227,13 @@ var flowStepSchema = z.lazy(
|
|
|
14172
14227
|
type: z.literal("send"),
|
|
14173
14228
|
templateSlug: slugSchema,
|
|
14174
14229
|
providerOverride: z.string().optional(),
|
|
14175
|
-
vars: z.record(z.string(), z.unknown()).optional()
|
|
14230
|
+
vars: z.record(z.string(), z.unknown()).optional(),
|
|
14231
|
+
delivery: z.object({
|
|
14232
|
+
weekdaysOnly: z.boolean().optional(),
|
|
14233
|
+
timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
|
|
14234
|
+
useContactTimezone: z.boolean().optional(),
|
|
14235
|
+
timezone: z.string().optional()
|
|
14236
|
+
}).optional()
|
|
14176
14237
|
}),
|
|
14177
14238
|
z.object({
|
|
14178
14239
|
type: z.literal("tag"),
|
|
@@ -14907,6 +14968,7 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14907
14968
|
flowSlug: flow.slug,
|
|
14908
14969
|
flowVersion: flow.version,
|
|
14909
14970
|
emailAtEntry: sub.emailAtSubscribe,
|
|
14971
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
14910
14972
|
enteredAt: /* @__PURE__ */ new Date(),
|
|
14911
14973
|
status: "active",
|
|
14912
14974
|
currentStepIndex: 0,
|
|
@@ -15004,6 +15066,105 @@ function effectiveLowerBound(ctx, opts) {
|
|
|
15004
15066
|
}
|
|
15005
15067
|
return null;
|
|
15006
15068
|
}
|
|
15069
|
+
|
|
15070
|
+
// src/server/runner/delivery-window.ts
|
|
15071
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15072
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15073
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15074
|
+
let candidate = now;
|
|
15075
|
+
if (window2.timeOfDay) {
|
|
15076
|
+
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15077
|
+
const local = localParts(candidate, tz);
|
|
15078
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
15079
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
15080
|
+
candidate = todaySlot;
|
|
15081
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
15082
|
+
const next = addLocalDays(local, 1);
|
|
15083
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
15084
|
+
}
|
|
15085
|
+
}
|
|
15086
|
+
if (window2.weekdaysOnly) {
|
|
15087
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
15088
|
+
const local = localParts(candidate, tz);
|
|
15089
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
15090
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
15091
|
+
const moved = addLocalDays(local, shift);
|
|
15092
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
15093
|
+
}
|
|
15094
|
+
}
|
|
15095
|
+
return candidate;
|
|
15096
|
+
}
|
|
15097
|
+
function pickTimezone(window2, contactTimezone) {
|
|
15098
|
+
const candidates = [
|
|
15099
|
+
window2.useContactTimezone ? contactTimezone : void 0,
|
|
15100
|
+
window2.timezone,
|
|
15101
|
+
"UTC"
|
|
15102
|
+
];
|
|
15103
|
+
for (const tz of candidates) {
|
|
15104
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
15105
|
+
}
|
|
15106
|
+
return "UTC";
|
|
15107
|
+
}
|
|
15108
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
15109
|
+
function isValidTimezone(tz) {
|
|
15110
|
+
const cached = validatedZones.get(tz);
|
|
15111
|
+
if (cached !== void 0) return cached;
|
|
15112
|
+
let ok = true;
|
|
15113
|
+
try {
|
|
15114
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
15115
|
+
} catch {
|
|
15116
|
+
ok = false;
|
|
15117
|
+
}
|
|
15118
|
+
validatedZones.set(tz, ok);
|
|
15119
|
+
return ok;
|
|
15120
|
+
}
|
|
15121
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
15122
|
+
function formatterFor(tz) {
|
|
15123
|
+
let f = partFormatters.get(tz);
|
|
15124
|
+
if (!f) {
|
|
15125
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
15126
|
+
timeZone: tz,
|
|
15127
|
+
year: "numeric",
|
|
15128
|
+
month: "2-digit",
|
|
15129
|
+
day: "2-digit",
|
|
15130
|
+
hour: "2-digit",
|
|
15131
|
+
minute: "2-digit",
|
|
15132
|
+
second: "2-digit",
|
|
15133
|
+
weekday: "short",
|
|
15134
|
+
hour12: false
|
|
15135
|
+
});
|
|
15136
|
+
partFormatters.set(tz, f);
|
|
15137
|
+
}
|
|
15138
|
+
return f;
|
|
15139
|
+
}
|
|
15140
|
+
function localParts(date, tz) {
|
|
15141
|
+
const parts = {};
|
|
15142
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
15143
|
+
return {
|
|
15144
|
+
y: Number(parts.year),
|
|
15145
|
+
mo: Number(parts.month),
|
|
15146
|
+
d: Number(parts.day),
|
|
15147
|
+
hh: Number(parts.hour) % 24,
|
|
15148
|
+
// Intl emits '24' for midnight in some locales
|
|
15149
|
+
mi: Number(parts.minute),
|
|
15150
|
+
ss: Number(parts.second),
|
|
15151
|
+
weekday: parts.weekday
|
|
15152
|
+
};
|
|
15153
|
+
}
|
|
15154
|
+
function utcFromLocal(y, mo, d, hh, mi, tz) {
|
|
15155
|
+
let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
|
15156
|
+
for (let i = 0; i < 2; i++) {
|
|
15157
|
+
const p = localParts(new Date(ts), tz);
|
|
15158
|
+
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
|
15159
|
+
const offset = asUtc - ts;
|
|
15160
|
+
ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
|
|
15161
|
+
}
|
|
15162
|
+
return new Date(ts);
|
|
15163
|
+
}
|
|
15164
|
+
function addLocalDays(p, days) {
|
|
15165
|
+
const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
|
|
15166
|
+
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
15167
|
+
}
|
|
15007
15168
|
async function compileTemplate(mjml) {
|
|
15008
15169
|
const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
|
|
15009
15170
|
const plainText = derivePlaintext(out.html);
|
|
@@ -15121,6 +15282,9 @@ function makeHandlebars(extra) {
|
|
|
15121
15282
|
return hb;
|
|
15122
15283
|
}
|
|
15123
15284
|
|
|
15285
|
+
// src/server/runner/send.ts
|
|
15286
|
+
init_vars();
|
|
15287
|
+
|
|
15124
15288
|
// src/server/runner/suppression.ts
|
|
15125
15289
|
var SCOPES_BY_KIND = {
|
|
15126
15290
|
marketing: ["all", "marketing"],
|
|
@@ -15308,10 +15472,10 @@ async function getBucketStatus(ctx, fromEmail, kind) {
|
|
|
15308
15472
|
}
|
|
15309
15473
|
|
|
15310
15474
|
// src/server/runner/send.ts
|
|
15311
|
-
async function handleSend(run,
|
|
15312
|
-
const template = await ctx.collections.templates.findOne({ slug:
|
|
15475
|
+
async function handleSend(run, step2, contact, flow, ctx) {
|
|
15476
|
+
const template = await ctx.collections.templates.findOne({ slug: step2.templateSlug });
|
|
15313
15477
|
if (!template) {
|
|
15314
|
-
await failFlowRun(run, `template not found: ${
|
|
15478
|
+
await failFlowRun(run, `template not found: ${step2.templateSlug}`, ctx);
|
|
15315
15479
|
return;
|
|
15316
15480
|
}
|
|
15317
15481
|
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}`;
|
|
@@ -15323,8 +15487,8 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15323
15487
|
});
|
|
15324
15488
|
return;
|
|
15325
15489
|
}
|
|
15326
|
-
const providerName = pickProviderName(
|
|
15327
|
-
const renderCtx = buildRenderContext(contact, run,
|
|
15490
|
+
const providerName = pickProviderName(step2.providerOverride, template, ctx);
|
|
15491
|
+
const renderCtx = buildRenderContext(contact, run, step2.vars ?? {}, ctx);
|
|
15328
15492
|
let rendered;
|
|
15329
15493
|
try {
|
|
15330
15494
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
@@ -15361,7 +15525,7 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15361
15525
|
bodyHash: sha256(rendered.html),
|
|
15362
15526
|
status: "queued",
|
|
15363
15527
|
renderedFrom: rendered.fromName ? { name: rendered.fromName, email: rendered.fromEmail } : void 0,
|
|
15364
|
-
vars:
|
|
15528
|
+
vars: step2.vars ?? {}
|
|
15365
15529
|
})
|
|
15366
15530
|
);
|
|
15367
15531
|
await ctx.queues.send.add(
|
|
@@ -15401,13 +15565,29 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15401
15565
|
return;
|
|
15402
15566
|
}
|
|
15403
15567
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
15404
|
-
|
|
15405
|
-
|
|
15406
|
-
|
|
15407
|
-
|
|
15408
|
-
|
|
15409
|
-
|
|
15410
|
-
|
|
15568
|
+
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
15569
|
+
await ctx.collections.sends.updateOne(
|
|
15570
|
+
{ _id: send._id },
|
|
15571
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15572
|
+
);
|
|
15573
|
+
return;
|
|
15574
|
+
}
|
|
15575
|
+
let renderCtx;
|
|
15576
|
+
let rendered;
|
|
15577
|
+
try {
|
|
15578
|
+
const resolved = await resolveVars(ctx.varsAdapter, contact, {
|
|
15579
|
+
reason: "send",
|
|
15580
|
+
templateSlug: template.slug,
|
|
15581
|
+
flowSlug: run?.flowSlug,
|
|
15582
|
+
eventName: run?.triggerEvent?.name,
|
|
15583
|
+
eventProperties: run?.triggerEvent?.properties
|
|
15584
|
+
});
|
|
15585
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
15586
|
+
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
15587
|
+
} catch (err) {
|
|
15588
|
+
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
15589
|
+
throw err;
|
|
15590
|
+
}
|
|
15411
15591
|
const tracking = applyTracking(rendered.html, {
|
|
15412
15592
|
sendId: String(send._id),
|
|
15413
15593
|
publicUrl: ctx.config.publicUrl,
|
|
@@ -15434,6 +15614,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15434
15614
|
await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
|
|
15435
15615
|
return;
|
|
15436
15616
|
}
|
|
15617
|
+
const headers = send.kind === "marketing" ? {
|
|
15618
|
+
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15619
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15620
|
+
} : {};
|
|
15437
15621
|
try {
|
|
15438
15622
|
const result = await provider.send({
|
|
15439
15623
|
to: send.emailAtSend,
|
|
@@ -15443,10 +15627,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15443
15627
|
subject: rendered.subject,
|
|
15444
15628
|
html: tracking.html,
|
|
15445
15629
|
text: rendered.plainText,
|
|
15446
|
-
headers
|
|
15447
|
-
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15448
|
-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15449
|
-
},
|
|
15630
|
+
headers,
|
|
15450
15631
|
messageMeta: { sendId: String(send._id) }
|
|
15451
15632
|
});
|
|
15452
15633
|
await ctx.collections.sends.updateOne(
|
|
@@ -15483,7 +15664,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
15483
15664
|
}
|
|
15484
15665
|
return ctx.config.defaultProvider;
|
|
15485
15666
|
}
|
|
15486
|
-
function buildRenderContext(contact, run, vars, ctx) {
|
|
15667
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
15487
15668
|
const scope = "marketing";
|
|
15488
15669
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
15489
15670
|
const token = signUnsubscribeToken(
|
|
@@ -15492,8 +15673,10 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
15492
15673
|
);
|
|
15493
15674
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
15494
15675
|
return {
|
|
15676
|
+
...resolved,
|
|
15495
15677
|
contact,
|
|
15496
15678
|
vars,
|
|
15679
|
+
event: run?.triggerEvent?.properties ?? {},
|
|
15497
15680
|
unsubscribeUrl,
|
|
15498
15681
|
senderAddress: ctx.config.senderAddress
|
|
15499
15682
|
};
|
|
@@ -15556,8 +15739,8 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15556
15739
|
return;
|
|
15557
15740
|
}
|
|
15558
15741
|
const steps = await loadStepsForRun(run, flow, ctx);
|
|
15559
|
-
const
|
|
15560
|
-
if (!
|
|
15742
|
+
const step2 = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
|
|
15743
|
+
if (!step2) {
|
|
15561
15744
|
await completeFlowRun(run, "completed", ctx);
|
|
15562
15745
|
return;
|
|
15563
15746
|
}
|
|
@@ -15571,27 +15754,34 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15571
15754
|
await exitFlowRun(run, sub.status, ctx);
|
|
15572
15755
|
return;
|
|
15573
15756
|
}
|
|
15574
|
-
switch (
|
|
15757
|
+
switch (step2.type) {
|
|
15575
15758
|
case "wait":
|
|
15576
|
-
return handleWait(run,
|
|
15759
|
+
return handleWait(run, step2, ctx);
|
|
15577
15760
|
case "condition":
|
|
15578
|
-
return handleCondition(run,
|
|
15761
|
+
return handleCondition(run, step2, contact, ctx);
|
|
15579
15762
|
case "branch":
|
|
15580
|
-
return handleBranch(run,
|
|
15581
|
-
case "send":
|
|
15582
|
-
|
|
15763
|
+
return handleBranch(run, step2, contact, ctx);
|
|
15764
|
+
case "send": {
|
|
15765
|
+
if (step2.delivery) {
|
|
15766
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step2.delivery, contact.timezone);
|
|
15767
|
+
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
15768
|
+
return deferSendForWindow(run, deliverAt, ctx);
|
|
15769
|
+
}
|
|
15770
|
+
}
|
|
15771
|
+
return handleSend(run, step2, contact, flow, ctx);
|
|
15772
|
+
}
|
|
15583
15773
|
case "tag":
|
|
15584
|
-
return handleTag(run,
|
|
15774
|
+
return handleTag(run, step2, ctx);
|
|
15585
15775
|
case "fire_event":
|
|
15586
|
-
return handleFireEvent(run,
|
|
15776
|
+
return handleFireEvent(run, step2, ctx);
|
|
15587
15777
|
case "webhook":
|
|
15588
|
-
return handleWebhookStep(run,
|
|
15778
|
+
return handleWebhookStep(run, step2, ctx);
|
|
15589
15779
|
case "exit":
|
|
15590
|
-
return exitFlowRun(run,
|
|
15780
|
+
return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
|
|
15591
15781
|
}
|
|
15592
15782
|
}
|
|
15593
|
-
async function handleWait(run,
|
|
15594
|
-
const ms = unitToMs(
|
|
15783
|
+
async function handleWait(run, step2, ctx) {
|
|
15784
|
+
const ms = unitToMs(step2.value, step2.unit);
|
|
15595
15785
|
const nextAt = new Date(Date.now() + ms);
|
|
15596
15786
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15597
15787
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15616,22 +15806,22 @@ async function handleWait(run, step, ctx) {
|
|
|
15616
15806
|
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
15617
15807
|
);
|
|
15618
15808
|
}
|
|
15619
|
-
async function handleCondition(run,
|
|
15809
|
+
async function handleCondition(run, step2, contact, ctx) {
|
|
15620
15810
|
if (!contact) return;
|
|
15621
|
-
const result = await evaluatePredicate(
|
|
15811
|
+
const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
|
|
15622
15812
|
if (result) {
|
|
15623
15813
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
15624
15814
|
return;
|
|
15625
15815
|
}
|
|
15626
|
-
if (
|
|
15816
|
+
if (step2.ifFalse === "continue") {
|
|
15627
15817
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: false, skipped: 1 } }, { stepInc: 2 });
|
|
15628
15818
|
return;
|
|
15629
15819
|
}
|
|
15630
15820
|
await exitFlowRun(run, "condition_false", ctx);
|
|
15631
15821
|
}
|
|
15632
|
-
async function handleBranch(run,
|
|
15822
|
+
async function handleBranch(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
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
15636
15826
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15637
15827
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15657,9 +15847,9 @@ async function handleBranch(run, step, contact, ctx) {
|
|
|
15657
15847
|
if (!updated) return;
|
|
15658
15848
|
await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
|
|
15659
15849
|
}
|
|
15660
|
-
async function handleTag(run,
|
|
15661
|
-
const adds =
|
|
15662
|
-
const removes =
|
|
15850
|
+
async function handleTag(run, step2, ctx) {
|
|
15851
|
+
const adds = step2.addTags ?? [];
|
|
15852
|
+
const removes = step2.removeTags ?? [];
|
|
15663
15853
|
if (ctx.adapter.addTags && adds.length > 0) {
|
|
15664
15854
|
await ctx.adapter.addTags(run.externalId, adds);
|
|
15665
15855
|
} else if (adds.length > 0) {
|
|
@@ -15680,13 +15870,13 @@ async function handleTag(run, step, ctx) {
|
|
|
15680
15870
|
}
|
|
15681
15871
|
await advanceStep(run, ctx, { action: "tagged", details: { addTags: adds, removeTags: removes } });
|
|
15682
15872
|
}
|
|
15683
|
-
async function handleFireEvent(run,
|
|
15684
|
-
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${
|
|
15873
|
+
async function handleFireEvent(run, step2, ctx) {
|
|
15874
|
+
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step2.eventName}`;
|
|
15685
15875
|
try {
|
|
15686
15876
|
await ctx.collections.events.insertOne({
|
|
15687
15877
|
externalId: run.externalId,
|
|
15688
|
-
name:
|
|
15689
|
-
properties:
|
|
15878
|
+
name: step2.eventName,
|
|
15879
|
+
properties: step2.properties ?? {},
|
|
15690
15880
|
dedupeKey,
|
|
15691
15881
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
15692
15882
|
createdAt: /* @__PURE__ */ new Date()
|
|
@@ -15694,27 +15884,27 @@ async function handleFireEvent(run, step, ctx) {
|
|
|
15694
15884
|
} catch (err) {
|
|
15695
15885
|
if (err?.code !== 11e3) throw err;
|
|
15696
15886
|
}
|
|
15697
|
-
await advanceStep(run, ctx, { action: "event_fired", details: { name:
|
|
15887
|
+
await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
|
|
15698
15888
|
}
|
|
15699
|
-
async function handleWebhookStep(run,
|
|
15889
|
+
async function handleWebhookStep(run, step2, ctx) {
|
|
15700
15890
|
try {
|
|
15701
|
-
const res = await fetch(
|
|
15702
|
-
method:
|
|
15891
|
+
const res = await fetch(step2.url, {
|
|
15892
|
+
method: step2.method ?? "POST",
|
|
15703
15893
|
headers: { "Content-Type": "application/json" },
|
|
15704
|
-
body: JSON.stringify(
|
|
15894
|
+
body: JSON.stringify(step2.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
|
|
15705
15895
|
signal: AbortSignal.timeout(1e4)
|
|
15706
15896
|
});
|
|
15707
15897
|
if (!res.ok) throw new Error(`webhook step returned ${res.status}`);
|
|
15708
|
-
await advanceStep(run, ctx, { action: "webhook_called", details: { url:
|
|
15898
|
+
await advanceStep(run, ctx, { action: "webhook_called", details: { url: step2.url, status: res.status } });
|
|
15709
15899
|
} catch (err) {
|
|
15710
15900
|
const attempts = (run.attemptsForCurrentStep ?? 0) + 1;
|
|
15711
15901
|
if (attempts >= ctx.config.webhookRetryAttempts) {
|
|
15712
|
-
if (
|
|
15902
|
+
if (step2.failureMode === "fail_run") {
|
|
15713
15903
|
await failFlowRun(run, `webhook failed: ${err?.message}`, ctx);
|
|
15714
15904
|
} else {
|
|
15715
15905
|
await advanceStep(run, ctx, {
|
|
15716
15906
|
action: "webhook_called",
|
|
15717
|
-
details: { url:
|
|
15907
|
+
details: { url: step2.url, error: err?.message, exhausted: true }
|
|
15718
15908
|
});
|
|
15719
15909
|
}
|
|
15720
15910
|
} else {
|
|
@@ -15730,6 +15920,34 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
15730
15920
|
}
|
|
15731
15921
|
}
|
|
15732
15922
|
}
|
|
15923
|
+
async function deferSendForWindow(run, deliverAt, ctx) {
|
|
15924
|
+
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15925
|
+
// Only write once per deferral — if nextActionAt already points at (or
|
|
15926
|
+
// past) the slot, another worker/tick got here first.
|
|
15927
|
+
{ _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
|
|
15928
|
+
{
|
|
15929
|
+
$set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
|
|
15930
|
+
$push: {
|
|
15931
|
+
history: {
|
|
15932
|
+
stepIndex: run.currentStepIndex,
|
|
15933
|
+
action: "send_deferred",
|
|
15934
|
+
at: /* @__PURE__ */ new Date(),
|
|
15935
|
+
details: { until: deliverAt }
|
|
15936
|
+
}
|
|
15937
|
+
}
|
|
15938
|
+
},
|
|
15939
|
+
{ returnDocument: "after" }
|
|
15940
|
+
);
|
|
15941
|
+
if (!updated) return;
|
|
15942
|
+
await ctx.queues.advance.add(
|
|
15943
|
+
"advance",
|
|
15944
|
+
{ flowRunId: String(run._id) },
|
|
15945
|
+
{
|
|
15946
|
+
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15947
|
+
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15948
|
+
}
|
|
15949
|
+
);
|
|
15950
|
+
}
|
|
15733
15951
|
async function advanceStep(run, ctx, log, opts = {}) {
|
|
15734
15952
|
const stepInc = opts.stepInc ?? 1;
|
|
15735
15953
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
@@ -16982,6 +17200,7 @@ var Mailer = class _Mailer {
|
|
|
16982
17200
|
db: this.db,
|
|
16983
17201
|
collections: this.collections,
|
|
16984
17202
|
adapter: this.adapter,
|
|
17203
|
+
varsAdapter: this.config.varsAdapter,
|
|
16985
17204
|
providers: this.providers,
|
|
16986
17205
|
queues: this.queues,
|
|
16987
17206
|
config: this.config,
|
|
@@ -17061,6 +17280,10 @@ var Mailer = class _Mailer {
|
|
|
17061
17280
|
if (!config.providers[config.defaultProvider]) {
|
|
17062
17281
|
throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
|
|
17063
17282
|
}
|
|
17283
|
+
if (config.varsAdapter) {
|
|
17284
|
+
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
17285
|
+
assertNoReservedVarKeys2(config.varsAdapter);
|
|
17286
|
+
}
|
|
17064
17287
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
17065
17288
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
17066
17289
|
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
@@ -17276,6 +17499,72 @@ var Mailer = class _Mailer {
|
|
|
17276
17499
|
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
17277
17500
|
}
|
|
17278
17501
|
}
|
|
17502
|
+
// -------------------------------------------------------------------------
|
|
17503
|
+
// Flow abort
|
|
17504
|
+
// -------------------------------------------------------------------------
|
|
17505
|
+
/**
|
|
17506
|
+
* Abort every active run of one flow for a contact, immediately. Runs parked
|
|
17507
|
+
* in a `wait` exit too — their delayed wake-up jobs find the run exited and
|
|
17508
|
+
* no-op. Also cancels any of the flow's emails still sitting in the send
|
|
17509
|
+
* queue for this contact (queued or awaiting retry), so an abort means no
|
|
17510
|
+
* further mail, not just no further steps.
|
|
17511
|
+
*
|
|
17512
|
+
* No-op (returns zero counts) when nothing is active. Safe to call from the
|
|
17513
|
+
* same handler that processes the business event ("user upgraded").
|
|
17514
|
+
*/
|
|
17515
|
+
async abortFlow(flowSlug, externalId, opts = {}) {
|
|
17516
|
+
const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
|
|
17517
|
+
const flow = await this.collections.flows.findOne(
|
|
17518
|
+
{ slug: parsed.flowSlug },
|
|
17519
|
+
{ projection: { _id: 1 } }
|
|
17520
|
+
);
|
|
17521
|
+
if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
|
|
17522
|
+
const result = await this.abortActiveRuns(
|
|
17523
|
+
{ externalId: parsed.externalId, flowId: flow._id },
|
|
17524
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
17525
|
+
);
|
|
17526
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
17527
|
+
await this.audit({
|
|
17528
|
+
actor: "host",
|
|
17529
|
+
action: "flow.abort",
|
|
17530
|
+
resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
|
|
17531
|
+
diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
17532
|
+
});
|
|
17533
|
+
}
|
|
17534
|
+
return result;
|
|
17535
|
+
}
|
|
17536
|
+
/**
|
|
17537
|
+
* Abort every active flow run for a contact across all flows. Same semantics
|
|
17538
|
+
* as `abortFlow` — for "stop everything" events (account deleted, churned).
|
|
17539
|
+
*/
|
|
17540
|
+
async abortAllFlows(externalId, opts = {}) {
|
|
17541
|
+
const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
|
|
17542
|
+
const result = await this.abortActiveRuns(
|
|
17543
|
+
{ externalId: parsed.externalId },
|
|
17544
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
17545
|
+
);
|
|
17546
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
17547
|
+
await this.audit({
|
|
17548
|
+
actor: "host",
|
|
17549
|
+
action: "flow.abort_all",
|
|
17550
|
+
resource: { collection: "mailer_flow_runs" },
|
|
17551
|
+
diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
17552
|
+
});
|
|
17553
|
+
}
|
|
17554
|
+
return result;
|
|
17555
|
+
}
|
|
17556
|
+
async abortActiveRuns(filter, exitReason) {
|
|
17557
|
+
const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
|
|
17558
|
+
if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
|
|
17559
|
+
for (const run of runs) {
|
|
17560
|
+
await exitFlowRun(run, exitReason, this.runnerContext);
|
|
17561
|
+
}
|
|
17562
|
+
const cancelled = await this.collections.sends.updateMany(
|
|
17563
|
+
{ flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
|
|
17564
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
17565
|
+
);
|
|
17566
|
+
return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
|
|
17567
|
+
}
|
|
17279
17568
|
/**
|
|
17280
17569
|
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
17281
17570
|
* suppression row to block re-import. INVARIANT 9.
|
|
@@ -17551,6 +17840,207 @@ function matches(c, f) {
|
|
|
17551
17840
|
return true;
|
|
17552
17841
|
}
|
|
17553
17842
|
|
|
17843
|
+
// src/testing/recording-provider.ts
|
|
17844
|
+
var RecordingProvider = class {
|
|
17845
|
+
constructor(inner) {
|
|
17846
|
+
this.inner = inner;
|
|
17847
|
+
}
|
|
17848
|
+
inner;
|
|
17849
|
+
/** Every `SendArgs` handed to the provider, in call order. */
|
|
17850
|
+
sent = [];
|
|
17851
|
+
/** Same calls, with outcome attached. */
|
|
17852
|
+
records = [];
|
|
17853
|
+
get name() {
|
|
17854
|
+
return this.inner.name;
|
|
17855
|
+
}
|
|
17856
|
+
get sendRatePerSecond() {
|
|
17857
|
+
return this.inner.sendRatePerSecond;
|
|
17858
|
+
}
|
|
17859
|
+
async send(args) {
|
|
17860
|
+
this.sent.push(args);
|
|
17861
|
+
const record = { args, result: null, error: null, durationMs: 0, at: /* @__PURE__ */ new Date() };
|
|
17862
|
+
this.records.push(record);
|
|
17863
|
+
const startedAt = Date.now();
|
|
17864
|
+
try {
|
|
17865
|
+
const result = await this.inner.send(args);
|
|
17866
|
+
record.result = result;
|
|
17867
|
+
return result;
|
|
17868
|
+
} catch (err) {
|
|
17869
|
+
record.error = err instanceof Error ? err : new Error(String(err));
|
|
17870
|
+
throw err;
|
|
17871
|
+
} finally {
|
|
17872
|
+
record.durationMs = Date.now() - startedAt;
|
|
17873
|
+
}
|
|
17874
|
+
}
|
|
17875
|
+
async verifyWebhook(rawBody, headers) {
|
|
17876
|
+
return this.inner.verifyWebhook(rawBody, headers);
|
|
17877
|
+
}
|
|
17878
|
+
parseWebhookEvents(payload, headers) {
|
|
17879
|
+
return this.inner.parseWebhookEvents(payload, headers);
|
|
17880
|
+
}
|
|
17881
|
+
/** Last recorded send, or undefined. Sugar for the common single-send assertion. */
|
|
17882
|
+
get last() {
|
|
17883
|
+
return this.sent[this.sent.length - 1];
|
|
17884
|
+
}
|
|
17885
|
+
/** All sends addressed to `email` (case-insensitive). */
|
|
17886
|
+
toRecipient(email) {
|
|
17887
|
+
const lower = email.toLowerCase();
|
|
17888
|
+
return this.sent.filter((s) => s.to.toLowerCase() === lower);
|
|
17889
|
+
}
|
|
17890
|
+
reset() {
|
|
17891
|
+
this.sent.length = 0;
|
|
17892
|
+
this.records.length = 0;
|
|
17893
|
+
}
|
|
17894
|
+
};
|
|
17895
|
+
|
|
17896
|
+
// src/testing/builders.ts
|
|
17897
|
+
var BACKDATE_MS = 6e4;
|
|
17898
|
+
async function buildTemplate(spec) {
|
|
17899
|
+
const now = /* @__PURE__ */ new Date();
|
|
17900
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
17901
|
+
let mjml = "";
|
|
17902
|
+
let html;
|
|
17903
|
+
if (spec.html) {
|
|
17904
|
+
html = spec.html;
|
|
17905
|
+
} else {
|
|
17906
|
+
mjml = spec.mjml ?? wrapMjml(spec.text ?? "Hello {{contact.fields.firstName}}");
|
|
17907
|
+
const compiled = await compileTemplate(mjml);
|
|
17908
|
+
html = compiled.html;
|
|
17909
|
+
}
|
|
17910
|
+
const plainText = spec.plainText ?? derivePlaintext(html);
|
|
17911
|
+
return {
|
|
17912
|
+
slug: spec.slug,
|
|
17913
|
+
name: spec.name ?? spec.slug,
|
|
17914
|
+
description: spec.description ?? "",
|
|
17915
|
+
kind: spec.kind ?? "marketing",
|
|
17916
|
+
fromName: spec.fromName ?? "Test",
|
|
17917
|
+
fromEmail: spec.fromEmail ?? "hello@example.com",
|
|
17918
|
+
replyTo: spec.replyTo ?? null,
|
|
17919
|
+
providerOverride: spec.providerOverride ?? null,
|
|
17920
|
+
subject: spec.subject ?? "Test subject",
|
|
17921
|
+
preheader: spec.preheader ?? "",
|
|
17922
|
+
body: { mjml, editorJson: null, html, plainText, compiledAt: createdAt },
|
|
17923
|
+
variablesSchema: spec.variablesSchema ?? {},
|
|
17924
|
+
draft: null,
|
|
17925
|
+
tags: spec.tags ?? [],
|
|
17926
|
+
trackOpens: spec.trackOpens ?? false,
|
|
17927
|
+
trackClicks: spec.trackClicks ?? false,
|
|
17928
|
+
stats: {
|
|
17929
|
+
sent: 0,
|
|
17930
|
+
delivered: 0,
|
|
17931
|
+
opened: 0,
|
|
17932
|
+
clicked: 0,
|
|
17933
|
+
bounced: 0,
|
|
17934
|
+
complained: 0,
|
|
17935
|
+
unsubscribed: 0,
|
|
17936
|
+
lastSentAt: null
|
|
17937
|
+
},
|
|
17938
|
+
publishedAt: spec.published === false ? null : createdAt,
|
|
17939
|
+
publishedBy: spec.published === false ? null : "test",
|
|
17940
|
+
createdAt,
|
|
17941
|
+
updatedAt: createdAt
|
|
17942
|
+
};
|
|
17943
|
+
}
|
|
17944
|
+
function wrapMjml(body) {
|
|
17945
|
+
return `<mjml><mj-body><mj-section><mj-column><mj-text>${body}</mj-text></mj-column></mj-section></mj-body></mjml>`;
|
|
17946
|
+
}
|
|
17947
|
+
function buildFlow(spec) {
|
|
17948
|
+
const now = /* @__PURE__ */ new Date();
|
|
17949
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
17950
|
+
const trigger = spec.trigger ?? {
|
|
17951
|
+
type: "event",
|
|
17952
|
+
eventName: spec.eventName ?? `${spec.slug}:trigger`,
|
|
17953
|
+
once: spec.once ?? true
|
|
17954
|
+
};
|
|
17955
|
+
return {
|
|
17956
|
+
slug: spec.slug,
|
|
17957
|
+
name: spec.name ?? spec.slug,
|
|
17958
|
+
description: spec.description ?? "",
|
|
17959
|
+
trigger,
|
|
17960
|
+
enabled: spec.enabled ?? true,
|
|
17961
|
+
steps: spec.steps,
|
|
17962
|
+
version: spec.version ?? 1,
|
|
17963
|
+
draft: null,
|
|
17964
|
+
goal: spec.goal ?? "activation",
|
|
17965
|
+
audience: spec.audience ?? "test",
|
|
17966
|
+
expectedVolumePerWeek: null,
|
|
17967
|
+
stats: { activeRuns: 0, completedRuns: 0, sendsTotal: 0, sendsLast7Days: 0 },
|
|
17968
|
+
lastTriggerScanAt: null,
|
|
17969
|
+
publishedAt: createdAt,
|
|
17970
|
+
publishedBy: "test",
|
|
17971
|
+
createdAt,
|
|
17972
|
+
updatedAt: createdAt
|
|
17973
|
+
};
|
|
17974
|
+
}
|
|
17975
|
+
var step = {
|
|
17976
|
+
send(templateSlug, opts = {}) {
|
|
17977
|
+
return { type: "send", templateSlug, ...opts };
|
|
17978
|
+
},
|
|
17979
|
+
sendAt(templateSlug, delivery) {
|
|
17980
|
+
return { type: "send", templateSlug, delivery };
|
|
17981
|
+
},
|
|
17982
|
+
wait(value, unit = "minutes") {
|
|
17983
|
+
return { type: "wait", value, unit };
|
|
17984
|
+
},
|
|
17985
|
+
tag(addTags, removeTags) {
|
|
17986
|
+
return { type: "tag", addTags, removeTags };
|
|
17987
|
+
},
|
|
17988
|
+
exit(reason) {
|
|
17989
|
+
return { type: "exit", reason };
|
|
17990
|
+
},
|
|
17991
|
+
fireEvent(eventName, properties) {
|
|
17992
|
+
return { type: "fire_event", eventName, properties };
|
|
17993
|
+
}
|
|
17994
|
+
};
|
|
17995
|
+
|
|
17996
|
+
// src/testing/drive.ts
|
|
17997
|
+
async function drain(ctx, opts = {}) {
|
|
17998
|
+
const maxRounds = opts.maxRounds ?? 100;
|
|
17999
|
+
const shouldDispatch = opts.dispatch !== false;
|
|
18000
|
+
const attempted = /* @__PURE__ */ new Set();
|
|
18001
|
+
const errors = [];
|
|
18002
|
+
let dispatched = 0;
|
|
18003
|
+
let rounds = 0;
|
|
18004
|
+
while (rounds < maxRounds) {
|
|
18005
|
+
rounds++;
|
|
18006
|
+
await processNewlyFiredEventTriggers(ctx);
|
|
18007
|
+
await sweepStrandedFlowRuns(ctx);
|
|
18008
|
+
let didWork = false;
|
|
18009
|
+
if (shouldDispatch) {
|
|
18010
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18011
|
+
for (const row of queued) {
|
|
18012
|
+
const id = String(row._id);
|
|
18013
|
+
if (attempted.has(id)) continue;
|
|
18014
|
+
attempted.add(id);
|
|
18015
|
+
didWork = true;
|
|
18016
|
+
dispatched++;
|
|
18017
|
+
try {
|
|
18018
|
+
await dispatchSend(row._id, ctx);
|
|
18019
|
+
} catch (err) {
|
|
18020
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
18021
|
+
if (opts.throwOnSendError) throw err;
|
|
18022
|
+
}
|
|
18023
|
+
}
|
|
18024
|
+
}
|
|
18025
|
+
const due = await ctx.collections.flowRuns.countDocuments({
|
|
18026
|
+
status: "active",
|
|
18027
|
+
nextActionAt: { $lte: /* @__PURE__ */ new Date() }
|
|
18028
|
+
});
|
|
18029
|
+
if (!didWork && due === 0) {
|
|
18030
|
+
return { rounds, dispatched, errors, settled: true };
|
|
18031
|
+
}
|
|
18032
|
+
}
|
|
18033
|
+
return { rounds, dispatched, errors, settled: false };
|
|
18034
|
+
}
|
|
18035
|
+
async function dispatchQueued(ctx) {
|
|
18036
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18037
|
+
for (const row of queued) {
|
|
18038
|
+
await dispatchSend(row._id, ctx).catch(() => {
|
|
18039
|
+
});
|
|
18040
|
+
}
|
|
18041
|
+
return queued.length;
|
|
18042
|
+
}
|
|
18043
|
+
|
|
17554
18044
|
// src/testing/index.ts
|
|
17555
18045
|
async function createTestMailer(opts = {}) {
|
|
17556
18046
|
const { MongoMemoryServer } = await Promise.resolve().then(() => __toESM(require_mongodb_memory_server()));
|
|
@@ -17560,13 +18050,13 @@ async function createTestMailer(opts = {}) {
|
|
|
17560
18050
|
const db = client.db("mailery-test");
|
|
17561
18051
|
const memoryAdapter = opts.adapter ? null : new MemoryContactAdapter(opts.seedContacts ?? []);
|
|
17562
18052
|
const adapter = opts.adapter ?? memoryAdapter;
|
|
17563
|
-
const provider = opts.provider ??
|
|
18053
|
+
const provider = new RecordingProvider(await resolveProvider(opts.provider ?? "null"));
|
|
17564
18054
|
const mailer = await Mailer.init({
|
|
17565
18055
|
db,
|
|
17566
18056
|
adapter,
|
|
17567
|
-
queue: { driver: "noop" },
|
|
17568
|
-
providers: {
|
|
17569
|
-
defaultProvider:
|
|
18057
|
+
queue: opts.queue ?? { driver: "noop" },
|
|
18058
|
+
providers: { [provider.name]: provider },
|
|
18059
|
+
defaultProvider: provider.name,
|
|
17570
18060
|
publicUrl: "http://localhost:3000",
|
|
17571
18061
|
unsubscribeSecret: "test-unsub-secret-32-bytes-or-more-please",
|
|
17572
18062
|
senderAddress: "1 Test St, Brooklyn NY 11201",
|
|
@@ -17574,17 +18064,72 @@ async function createTestMailer(opts = {}) {
|
|
|
17574
18064
|
workerless: true,
|
|
17575
18065
|
...opts.config ?? {}
|
|
17576
18066
|
});
|
|
17577
|
-
|
|
17578
|
-
|
|
17579
|
-
|
|
17580
|
-
|
|
17581
|
-
|
|
17582
|
-
|
|
17583
|
-
|
|
18067
|
+
if (opts.startWorkers) await mailer.startWorkers();
|
|
18068
|
+
const ctx = mailer.getRunnerContext();
|
|
18069
|
+
const harness = {
|
|
18070
|
+
mailer,
|
|
18071
|
+
db,
|
|
18072
|
+
provider,
|
|
18073
|
+
adapter,
|
|
18074
|
+
memoryAdapter,
|
|
18075
|
+
ctx,
|
|
18076
|
+
async seedContact(contact, seedOpts = {}) {
|
|
18077
|
+
if (!memoryAdapter) {
|
|
18078
|
+
throw new Error("seedContact requires the built-in MemoryContactAdapter \u2014 you passed a custom `adapter`.");
|
|
18079
|
+
}
|
|
18080
|
+
memoryAdapter.upsert(contact);
|
|
18081
|
+
if (seedOpts.subscribe !== false) {
|
|
18082
|
+
await mailer.upsertSubscription({
|
|
18083
|
+
externalId: contact.externalId,
|
|
18084
|
+
source: seedOpts.source ?? "test"
|
|
18085
|
+
});
|
|
18086
|
+
}
|
|
18087
|
+
return contact;
|
|
18088
|
+
},
|
|
18089
|
+
async seedTemplate(spec) {
|
|
18090
|
+
const doc = await buildTemplate(spec);
|
|
18091
|
+
const res = await ctx.collections.templates.insertOne(doc);
|
|
18092
|
+
doc._id = res.insertedId;
|
|
18093
|
+
return doc;
|
|
18094
|
+
},
|
|
18095
|
+
async seedFlow(spec) {
|
|
18096
|
+
const doc = buildFlow(spec);
|
|
18097
|
+
const res = await ctx.collections.flows.insertOne(doc);
|
|
18098
|
+
doc._id = res.insertedId;
|
|
18099
|
+
return doc;
|
|
18100
|
+
},
|
|
18101
|
+
drain(drainOpts) {
|
|
18102
|
+
return drain(ctx, drainOpts);
|
|
18103
|
+
},
|
|
18104
|
+
stop: async () => {
|
|
18105
|
+
await mailer.stop().catch(() => {
|
|
18106
|
+
});
|
|
18107
|
+
await client.close().catch(() => {
|
|
18108
|
+
});
|
|
18109
|
+
await mongo.stop().catch(() => {
|
|
18110
|
+
});
|
|
18111
|
+
}
|
|
17584
18112
|
};
|
|
17585
|
-
return
|
|
18113
|
+
return harness;
|
|
18114
|
+
}
|
|
18115
|
+
async function resolveProvider(spec) {
|
|
18116
|
+
if (typeof spec !== "string") return spec;
|
|
18117
|
+
if (spec === "null") return new NullProvider();
|
|
18118
|
+
const apiKey = process.env.SENDGRID_API_KEY;
|
|
18119
|
+
if (!apiKey) {
|
|
18120
|
+
throw new Error(
|
|
18121
|
+
"createTestMailer({ provider: 'sendgrid' }) requires SENDGRID_API_KEY. Live tests should gate on it before constructing the harness."
|
|
18122
|
+
);
|
|
18123
|
+
}
|
|
18124
|
+
const { SendGridProvider: SendGridProvider2 } = await Promise.resolve().then(() => (init_sendgrid(), sendgrid_exports));
|
|
18125
|
+
return new SendGridProvider2({
|
|
18126
|
+
apiKey,
|
|
18127
|
+
// Sandbox unless explicitly told to deliver: SendGrid still authenticates
|
|
18128
|
+
// and validates the payload, it just never puts anything in an inbox.
|
|
18129
|
+
sandbox: process.env.MAILERY_LIVE_E2E !== "deliver"
|
|
18130
|
+
});
|
|
17586
18131
|
}
|
|
17587
18132
|
|
|
17588
|
-
export { MemoryContactAdapter, NullProvider, createTestMailer };
|
|
18133
|
+
export { MemoryContactAdapter, NullProvider, RecordingProvider, buildFlow, buildTemplate, createTestMailer, dispatchQueued, drain, step, wrapMjml };
|
|
17589
18134
|
//# sourceMappingURL=testing.js.map
|
|
17590
18135
|
//# sourceMappingURL=testing.js.map
|