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.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
- step(generator.next(value));
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
- step(generator["throw"](value));
501
+ step2(generator["throw"](value));
453
502
  } catch (e) {
454
503
  reject(e);
455
504
  }
456
505
  }
457
- function step(result) {
506
+ function step2(result) {
458
507
  result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
459
508
  }
460
- step((generator = generator.apply(thisArg, _arguments || [])).next());
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 step([n, v]);
522
+ return step2([n, v]);
474
523
  };
475
524
  }
476
- function step(op) {
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
- step(g[n](v));
659
+ step2(g[n](v));
611
660
  } catch (e) {
612
661
  settle(q[0][3], e);
613
662
  }
614
663
  }
615
- function step(r) {
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 z2 = options2.includePrerelease ? "-0" : "";
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${z2} <${+M + 1}.0.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${z2} <${M}.${+m + 1}.0-0`;
3054
+ ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
3006
3055
  } else {
3007
- ret = `>=${M}.${m}.0${z2} <${+M + 1}.0.0-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}${z2} <${M}.${m}.${+p + 1}-0`;
3073
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
3025
3074
  } else {
3026
- ret = `>=${M}.${m}.${p}${z2} <${M}.${+m + 1}.0-0`;
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 drain = this._drain;
7082
+ const drain2 = this._drain;
7034
7083
  this._drain = noop;
7035
- drain();
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, step, contact, flow, ctx) {
15312
- const template = await ctx.collections.templates.findOne({ slug: step.templateSlug });
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: ${step.templateSlug}`, ctx);
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(step.providerOverride, template, ctx);
15327
- const renderCtx = buildRenderContext(contact, run, step.vars ?? {}, ctx);
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: step.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
- const renderCtx = buildRenderContext(
15405
- contact,
15406
- run,
15407
- send.vars ?? {},
15408
- ctx
15409
- );
15410
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
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 step = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
15560
- if (!step) {
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 (step.type) {
15757
+ switch (step2.type) {
15575
15758
  case "wait":
15576
- return handleWait(run, step, ctx);
15759
+ return handleWait(run, step2, ctx);
15577
15760
  case "condition":
15578
- return handleCondition(run, step, contact, ctx);
15761
+ return handleCondition(run, step2, contact, ctx);
15579
15762
  case "branch":
15580
- return handleBranch(run, step, contact, ctx);
15581
- case "send":
15582
- return handleSend(run, step, contact, flow, ctx);
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, step, ctx);
15774
+ return handleTag(run, step2, ctx);
15585
15775
  case "fire_event":
15586
- return handleFireEvent(run, step, ctx);
15776
+ return handleFireEvent(run, step2, ctx);
15587
15777
  case "webhook":
15588
- return handleWebhookStep(run, step, ctx);
15778
+ return handleWebhookStep(run, step2, ctx);
15589
15779
  case "exit":
15590
- return exitFlowRun(run, step.reason ?? "exit_step", ctx);
15780
+ return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
15591
15781
  }
15592
15782
  }
15593
- async function handleWait(run, step, ctx) {
15594
- const ms = unitToMs(step.value, step.unit);
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, step, contact, ctx) {
15809
+ async function handleCondition(run, step2, contact, ctx) {
15620
15810
  if (!contact) return;
15621
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
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 (step.ifFalse === "continue") {
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, step, contact, ctx) {
15822
+ async function handleBranch(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
  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, step, ctx) {
15661
- const adds = step.addTags ?? [];
15662
- const removes = step.removeTags ?? [];
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, step, ctx) {
15684
- const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step.eventName}`;
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: step.eventName,
15689
- properties: step.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: step.eventName } });
15887
+ await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
15698
15888
  }
15699
- async function handleWebhookStep(run, step, ctx) {
15889
+ async function handleWebhookStep(run, step2, ctx) {
15700
15890
  try {
15701
- const res = await fetch(step.url, {
15702
- method: step.method ?? "POST",
15891
+ const res = await fetch(step2.url, {
15892
+ method: step2.method ?? "POST",
15703
15893
  headers: { "Content-Type": "application/json" },
15704
- body: JSON.stringify(step.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
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: step.url, status: res.status } });
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 (step.failureMode === "fail_run") {
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: step.url, error: err?.message, exhausted: true }
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 ?? new NullProvider();
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: { null: provider },
17569
- defaultProvider: "null",
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
- const stop = async () => {
17578
- await mailer.stop().catch(() => {
17579
- });
17580
- await client.close().catch(() => {
17581
- });
17582
- await mongo.stop().catch(() => {
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 { mailer, db, provider, adapter, memoryAdapter, stop };
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