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/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
- step(generator.next(value));
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
- step(generator["throw"](value));
514
+ step2(generator["throw"](value));
466
515
  } catch (e) {
467
516
  reject(e);
468
517
  }
469
518
  }
470
- function step(result) {
519
+ function step2(result) {
471
520
  result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
472
521
  }
473
- step((generator = generator.apply(thisArg, _arguments || [])).next());
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 step([n, v]);
535
+ return step2([n, v]);
487
536
  };
488
537
  }
489
- function step(op) {
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
- step(g[n](v));
672
+ step2(g[n](v));
624
673
  } catch (e) {
625
674
  settle(q[0][3], e);
626
675
  }
627
676
  }
628
- function step(r) {
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 z2 = options2.includePrerelease ? "-0" : "";
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${z2} <${+M + 1}.0.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${z2} <${M}.${+m + 1}.0-0`;
3067
+ ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
3019
3068
  } else {
3020
- ret = `>=${M}.${m}.0${z2} <${+M + 1}.0.0-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}${z2} <${M}.${m}.${+p + 1}-0`;
3086
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
3038
3087
  } else {
3039
- ret = `>=${M}.${m}.${p}${z2} <${M}.${+m + 1}.0-0`;
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 drain = this._drain;
7095
+ const drain2 = this._drain;
7047
7096
  this._drain = noop;
7048
- drain();
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, step, contact, flow, ctx) {
15325
- const template = await ctx.collections.templates.findOne({ slug: step.templateSlug });
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: ${step.templateSlug}`, ctx);
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(step.providerOverride, template, ctx);
15340
- const renderCtx = buildRenderContext(contact, run, step.vars ?? {}, ctx);
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: step.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
- const renderCtx = buildRenderContext(
15418
- contact,
15419
- run,
15420
- send.vars ?? {},
15421
- ctx
15422
- );
15423
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
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 step = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
15573
- if (!step) {
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 (step.type) {
15770
+ switch (step2.type) {
15588
15771
  case "wait":
15589
- return handleWait(run, step, ctx);
15772
+ return handleWait(run, step2, ctx);
15590
15773
  case "condition":
15591
- return handleCondition(run, step, contact, ctx);
15774
+ return handleCondition(run, step2, contact, ctx);
15592
15775
  case "branch":
15593
- return handleBranch(run, step, contact, ctx);
15594
- case "send":
15595
- return handleSend(run, step, contact, flow, ctx);
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, step, ctx);
15787
+ return handleTag(run, step2, ctx);
15598
15788
  case "fire_event":
15599
- return handleFireEvent(run, step, ctx);
15789
+ return handleFireEvent(run, step2, ctx);
15600
15790
  case "webhook":
15601
- return handleWebhookStep(run, step, ctx);
15791
+ return handleWebhookStep(run, step2, ctx);
15602
15792
  case "exit":
15603
- return exitFlowRun(run, step.reason ?? "exit_step", ctx);
15793
+ return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
15604
15794
  }
15605
15795
  }
15606
- async function handleWait(run, step, ctx) {
15607
- const ms = unitToMs(step.value, step.unit);
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, step, contact, ctx) {
15822
+ async function handleCondition(run, step2, contact, ctx) {
15633
15823
  if (!contact) return;
15634
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
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 (step.ifFalse === "continue") {
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, step, contact, ctx) {
15835
+ async function handleBranch(run, step2, contact, ctx) {
15646
15836
  if (!contact) return;
15647
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
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, step, ctx) {
15674
- const adds = step.addTags ?? [];
15675
- const removes = step.removeTags ?? [];
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, step, ctx) {
15697
- const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step.eventName}`;
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: step.eventName,
15702
- properties: step.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: step.eventName } });
15900
+ await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
15711
15901
  }
15712
- async function handleWebhookStep(run, step, ctx) {
15902
+ async function handleWebhookStep(run, step2, ctx) {
15713
15903
  try {
15714
- const res = await fetch(step.url, {
15715
- method: step.method ?? "POST",
15904
+ const res = await fetch(step2.url, {
15905
+ method: step2.method ?? "POST",
15716
15906
  headers: { "Content-Type": "application/json" },
15717
- body: JSON.stringify(step.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
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: step.url, status: res.status } });
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 (step.failureMode === "fail_run") {
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: step.url, error: err?.message, exhausted: true }
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 ?? new NullProvider();
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: { null: provider },
17582
- defaultProvider: "null",
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
- const stop = async () => {
17591
- await mailer.stop().catch(() => {
17592
- });
17593
- await client.close().catch(() => {
17594
- });
17595
- await mongo.stop().catch(() => {
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 { mailer, db, provider, adapter, memoryAdapter, stop };
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