mailery 0.7.0 → 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.
@@ -527,6 +527,14 @@ interface MailTesterConfig {
527
527
  apiKey: string;
528
528
  /** Minimum score (0-10) below which publish is blocked. Default 8.0. */
529
529
  minScore?: number;
530
+ /**
531
+ * When true, publishing content that has no cached score is blocked too —
532
+ * the operator must run a deliverability check for that exact content first.
533
+ * Default false, which only blocks content already known to score below
534
+ * `minScore`; in that mode any edit (even whitespace) changes the content
535
+ * key, misses the cache, and publishes freely.
536
+ */
537
+ requireScore?: boolean;
530
538
  /**
531
539
  * How long a successful score remains cached for the same content. Default 24.
532
540
  * Re-publishing the same body within the window does not burn a credit.
@@ -1469,4 +1477,4 @@ declare class NullProvider implements MailProvider {
1469
1477
  reset(): void;
1470
1478
  }
1471
1479
 
1472
- export { getCollections as $, type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainValidation as G, type HealthDoc as H, type SubscriptionDoc as I, type SubscriptionStatus as J, type SuppressionDoc as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionReason as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type TemplateKind as U, type TemplateVersionDoc as V, type VarsAdapter as W, type VarsResolveInfo as X, type WebhookEventDoc as Y, defineVars as Z, ensureIndexes as _, type Contact as a, validateSenderDomain as a0, varsJsonSchema as a1, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type SuppressionScope as e, type SegmentFilter as f, type AuditLogDoc as g, type BroadcastStatus as h, type CircuitBreakerThresholds as i, type Collections as j, type ContactTagDoc as k, type FlowDoc as l, type FlowGoal as m, type FlowRunDoc as n, type FlowRunStatus as o, type FlowVersionDoc as p, type HealthStatus as q, type MailerConfig as r, NullProvider as s, RESERVED_VAR_KEYS as t, type RedisOptions as u, type SegmentDefinition as v, type SendDoc as w, type SendStatus as x, type SenderDomainConfig as y, type SenderDomainRegistry as z };
1480
+ export { getCollections as $, type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainValidation as G, type HealthDoc as H, type SubscriptionDoc as I, type SubscriptionStatus as J, type SuppressionDoc as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionReason as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type TemplateKind as U, type TemplateVersionDoc as V, type VarsAdapter as W, type VarsResolveInfo as X, type WebhookEventDoc as Y, defineVars as Z, ensureIndexes as _, type Contact as a, validateSenderDomain as a0, varsJsonSchema as a1, type FlowTrigger as a2, type QueueDriverConfig as a3, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type SuppressionScope as e, type SegmentFilter as f, type AuditLogDoc as g, type BroadcastStatus as h, type CircuitBreakerThresholds as i, type Collections as j, type ContactTagDoc as k, type FlowDoc as l, type FlowGoal as m, type FlowRunDoc as n, type FlowRunStatus as o, type FlowVersionDoc as p, type HealthStatus as q, type MailerConfig as r, NullProvider as s, RESERVED_VAR_KEYS as t, type RedisOptions as u, type SegmentDefinition as v, type SendDoc as w, type SendStatus as x, type SenderDomainConfig as y, type SenderDomainRegistry as z };
@@ -527,6 +527,14 @@ interface MailTesterConfig {
527
527
  apiKey: string;
528
528
  /** Minimum score (0-10) below which publish is blocked. Default 8.0. */
529
529
  minScore?: number;
530
+ /**
531
+ * When true, publishing content that has no cached score is blocked too —
532
+ * the operator must run a deliverability check for that exact content first.
533
+ * Default false, which only blocks content already known to score below
534
+ * `minScore`; in that mode any edit (even whitespace) changes the content
535
+ * key, misses the cache, and publishes freely.
536
+ */
537
+ requireScore?: boolean;
530
538
  /**
531
539
  * How long a successful score remains cached for the same content. Default 24.
532
540
  * Re-publishing the same body within the window does not burn a credit.
@@ -1469,4 +1477,4 @@ declare class NullProvider implements MailProvider {
1469
1477
  reset(): void;
1470
1478
  }
1471
1479
 
1472
- export { getCollections as $, type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainValidation as G, type HealthDoc as H, type SubscriptionDoc as I, type SubscriptionStatus as J, type SuppressionDoc as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionReason as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type TemplateKind as U, type TemplateVersionDoc as V, type VarsAdapter as W, type VarsResolveInfo as X, type WebhookEventDoc as Y, defineVars as Z, ensureIndexes as _, type Contact as a, validateSenderDomain as a0, varsJsonSchema as a1, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type SuppressionScope as e, type SegmentFilter as f, type AuditLogDoc as g, type BroadcastStatus as h, type CircuitBreakerThresholds as i, type Collections as j, type ContactTagDoc as k, type FlowDoc as l, type FlowGoal as m, type FlowRunDoc as n, type FlowRunStatus as o, type FlowVersionDoc as p, type HealthStatus as q, type MailerConfig as r, NullProvider as s, RESERVED_VAR_KEYS as t, type RedisOptions as u, type SegmentDefinition as v, type SendDoc as w, type SendStatus as x, type SenderDomainConfig as y, type SenderDomainRegistry as z };
1480
+ export { getCollections as $, type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type DeliveryWindow as D, type EventDoc as E, type FlowStep as F, type SenderDomainValidation as G, type HealthDoc as H, type SubscriptionDoc as I, type SubscriptionStatus as J, type SuppressionDoc as K, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type SuppressionReason as Q, type RunnerContext as R, type SendArgs as S, type TemplateDoc as T, type TemplateKind as U, type TemplateVersionDoc as V, type VarsAdapter as W, type VarsResolveInfo as X, type WebhookEventDoc as Y, defineVars as Z, ensureIndexes as _, type Contact as a, validateSenderDomain as a0, varsJsonSchema as a1, type FlowTrigger as a2, type QueueDriverConfig as a3, type SendResult as b, type MailTesterFeedback as c, Mailer as d, type SuppressionScope as e, type SegmentFilter as f, type AuditLogDoc as g, type BroadcastStatus as h, type CircuitBreakerThresholds as i, type Collections as j, type ContactTagDoc as k, type FlowDoc as l, type FlowGoal as m, type FlowRunDoc as n, type FlowRunStatus as o, type FlowVersionDoc as p, type HealthStatus as q, type MailerConfig as r, NullProvider as s, RESERVED_VAR_KEYS as t, type RedisOptions as u, type SegmentDefinition as v, type SendDoc as w, type SendStatus as x, type SenderDomainConfig as y, type SenderDomainRegistry as z };
package/dist/testing.cjs CHANGED
@@ -504,22 +504,22 @@ function __awaiter(thisArg, _arguments, P, generator) {
504
504
  return new (P || (P = Promise))(function(resolve, reject) {
505
505
  function fulfilled(value) {
506
506
  try {
507
- step(generator.next(value));
507
+ step2(generator.next(value));
508
508
  } catch (e) {
509
509
  reject(e);
510
510
  }
511
511
  }
512
512
  function rejected(value) {
513
513
  try {
514
- step(generator["throw"](value));
514
+ step2(generator["throw"](value));
515
515
  } catch (e) {
516
516
  reject(e);
517
517
  }
518
518
  }
519
- function step(result) {
519
+ function step2(result) {
520
520
  result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
521
521
  }
522
- step((generator = generator.apply(thisArg, _arguments || [])).next());
522
+ step2((generator = generator.apply(thisArg, _arguments || [])).next());
523
523
  });
524
524
  }
525
525
  function __generator(thisArg, body) {
@@ -532,10 +532,10 @@ function __generator(thisArg, body) {
532
532
  }), g;
533
533
  function verb(n) {
534
534
  return function(v) {
535
- return step([n, v]);
535
+ return step2([n, v]);
536
536
  };
537
537
  }
538
- function step(op) {
538
+ function step2(op) {
539
539
  if (f) throw new TypeError("Generator is already executing.");
540
540
  while (g && (g = 0, op[0] && (_ = 0)), _) try {
541
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;
@@ -669,12 +669,12 @@ function __asyncGenerator(thisArg, _arguments, generator) {
669
669
  }
670
670
  function resume(n, v) {
671
671
  try {
672
- step(g[n](v));
672
+ step2(g[n](v));
673
673
  } catch (e) {
674
674
  settle(q[0][3], e);
675
675
  }
676
676
  }
677
- function step(r) {
677
+ function step2(r) {
678
678
  r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r);
679
679
  }
680
680
  function fulfill(value) {
@@ -7092,9 +7092,9 @@ var require_pack = __commonJS({
7092
7092
  this.push(headers.encode(newHeader));
7093
7093
  }
7094
7094
  _doDrain() {
7095
- const drain = this._drain;
7095
+ const drain2 = this._drain;
7096
7096
  this._drain = noop;
7097
- drain();
7097
+ drain2();
7098
7098
  }
7099
7099
  _predestroy() {
7100
7100
  const err = getStreamError(this);
@@ -15485,10 +15485,10 @@ async function getBucketStatus(ctx, fromEmail, kind) {
15485
15485
  }
15486
15486
 
15487
15487
  // src/server/runner/send.ts
15488
- async function handleSend(run, step, contact, flow, ctx) {
15489
- 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 });
15490
15490
  if (!template) {
15491
- await failFlowRun(run, `template not found: ${step.templateSlug}`, ctx);
15491
+ await failFlowRun(run, `template not found: ${step2.templateSlug}`, ctx);
15492
15492
  return;
15493
15493
  }
15494
15494
  const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}`;
@@ -15500,8 +15500,8 @@ async function handleSend(run, step, contact, flow, ctx) {
15500
15500
  });
15501
15501
  return;
15502
15502
  }
15503
- const providerName = pickProviderName(step.providerOverride, template, ctx);
15504
- 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);
15505
15505
  let rendered;
15506
15506
  try {
15507
15507
  rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
@@ -15538,7 +15538,7 @@ async function handleSend(run, step, contact, flow, ctx) {
15538
15538
  bodyHash: sha256(rendered.html),
15539
15539
  status: "queued",
15540
15540
  renderedFrom: rendered.fromName ? { name: rendered.fromName, email: rendered.fromEmail } : void 0,
15541
- vars: step.vars ?? {}
15541
+ vars: step2.vars ?? {}
15542
15542
  })
15543
15543
  );
15544
15544
  await ctx.queues.send.add(
@@ -15627,6 +15627,10 @@ async function dispatchSend(sendId, ctx) {
15627
15627
  await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
15628
15628
  return;
15629
15629
  }
15630
+ const headers = send.kind === "marketing" ? {
15631
+ "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
15632
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
15633
+ } : {};
15630
15634
  try {
15631
15635
  const result = await provider.send({
15632
15636
  to: send.emailAtSend,
@@ -15636,10 +15640,7 @@ async function dispatchSend(sendId, ctx) {
15636
15640
  subject: rendered.subject,
15637
15641
  html: tracking.html,
15638
15642
  text: rendered.plainText,
15639
- headers: {
15640
- "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
15641
- "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
15642
- },
15643
+ headers,
15643
15644
  messageMeta: { sendId: String(send._id) }
15644
15645
  });
15645
15646
  await ctx.collections.sends.updateOne(
@@ -15751,8 +15752,8 @@ async function processOneRunStep(runId, ctx) {
15751
15752
  return;
15752
15753
  }
15753
15754
  const steps = await loadStepsForRun(run, flow, ctx);
15754
- const step = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
15755
- if (!step) {
15755
+ const step2 = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
15756
+ if (!step2) {
15756
15757
  await completeFlowRun(run, "completed", ctx);
15757
15758
  return;
15758
15759
  }
@@ -15766,34 +15767,34 @@ async function processOneRunStep(runId, ctx) {
15766
15767
  await exitFlowRun(run, sub.status, ctx);
15767
15768
  return;
15768
15769
  }
15769
- switch (step.type) {
15770
+ switch (step2.type) {
15770
15771
  case "wait":
15771
- return handleWait(run, step, ctx);
15772
+ return handleWait(run, step2, ctx);
15772
15773
  case "condition":
15773
- return handleCondition(run, step, contact, ctx);
15774
+ return handleCondition(run, step2, contact, ctx);
15774
15775
  case "branch":
15775
- return handleBranch(run, step, contact, ctx);
15776
+ return handleBranch(run, step2, contact, ctx);
15776
15777
  case "send": {
15777
- if (step.delivery) {
15778
- const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
15778
+ if (step2.delivery) {
15779
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step2.delivery, contact.timezone);
15779
15780
  if (deliverAt.getTime() > Date.now() + 3e4) {
15780
15781
  return deferSendForWindow(run, deliverAt, ctx);
15781
15782
  }
15782
15783
  }
15783
- return handleSend(run, step, contact, flow, ctx);
15784
+ return handleSend(run, step2, contact, flow, ctx);
15784
15785
  }
15785
15786
  case "tag":
15786
- return handleTag(run, step, ctx);
15787
+ return handleTag(run, step2, ctx);
15787
15788
  case "fire_event":
15788
- return handleFireEvent(run, step, ctx);
15789
+ return handleFireEvent(run, step2, ctx);
15789
15790
  case "webhook":
15790
- return handleWebhookStep(run, step, ctx);
15791
+ return handleWebhookStep(run, step2, ctx);
15791
15792
  case "exit":
15792
- return exitFlowRun(run, step.reason ?? "exit_step", ctx);
15793
+ return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
15793
15794
  }
15794
15795
  }
15795
- async function handleWait(run, step, ctx) {
15796
- const ms = unitToMs(step.value, step.unit);
15796
+ async function handleWait(run, step2, ctx) {
15797
+ const ms = unitToMs(step2.value, step2.unit);
15797
15798
  const nextAt = new Date(Date.now() + ms);
15798
15799
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
15799
15800
  { _id: run._id, currentStepIndex: run.currentStepIndex },
@@ -15818,22 +15819,22 @@ async function handleWait(run, step, ctx) {
15818
15819
  { delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
15819
15820
  );
15820
15821
  }
15821
- async function handleCondition(run, step, contact, ctx) {
15822
+ async function handleCondition(run, step2, contact, ctx) {
15822
15823
  if (!contact) return;
15823
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
15824
+ const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
15824
15825
  if (result) {
15825
15826
  await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
15826
15827
  return;
15827
15828
  }
15828
- if (step.ifFalse === "continue") {
15829
+ if (step2.ifFalse === "continue") {
15829
15830
  await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: false, skipped: 1 } }, { stepInc: 2 });
15830
15831
  return;
15831
15832
  }
15832
15833
  await exitFlowRun(run, "condition_false", ctx);
15833
15834
  }
15834
- async function handleBranch(run, step, contact, ctx) {
15835
+ async function handleBranch(run, step2, contact, ctx) {
15835
15836
  if (!contact) return;
15836
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
15837
+ const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
15837
15838
  const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
15838
15839
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
15839
15840
  { _id: run._id, currentStepIndex: run.currentStepIndex },
@@ -15859,9 +15860,9 @@ async function handleBranch(run, step, contact, ctx) {
15859
15860
  if (!updated) return;
15860
15861
  await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
15861
15862
  }
15862
- async function handleTag(run, step, ctx) {
15863
- const adds = step.addTags ?? [];
15864
- const removes = step.removeTags ?? [];
15863
+ async function handleTag(run, step2, ctx) {
15864
+ const adds = step2.addTags ?? [];
15865
+ const removes = step2.removeTags ?? [];
15865
15866
  if (ctx.adapter.addTags && adds.length > 0) {
15866
15867
  await ctx.adapter.addTags(run.externalId, adds);
15867
15868
  } else if (adds.length > 0) {
@@ -15882,13 +15883,13 @@ async function handleTag(run, step, ctx) {
15882
15883
  }
15883
15884
  await advanceStep(run, ctx, { action: "tagged", details: { addTags: adds, removeTags: removes } });
15884
15885
  }
15885
- async function handleFireEvent(run, step, ctx) {
15886
- 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}`;
15887
15888
  try {
15888
15889
  await ctx.collections.events.insertOne({
15889
15890
  externalId: run.externalId,
15890
- name: step.eventName,
15891
- properties: step.properties ?? {},
15891
+ name: step2.eventName,
15892
+ properties: step2.properties ?? {},
15892
15893
  dedupeKey,
15893
15894
  occurredAt: /* @__PURE__ */ new Date(),
15894
15895
  createdAt: /* @__PURE__ */ new Date()
@@ -15896,27 +15897,27 @@ async function handleFireEvent(run, step, ctx) {
15896
15897
  } catch (err) {
15897
15898
  if (err?.code !== 11e3) throw err;
15898
15899
  }
15899
- await advanceStep(run, ctx, { action: "event_fired", details: { name: step.eventName } });
15900
+ await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
15900
15901
  }
15901
- async function handleWebhookStep(run, step, ctx) {
15902
+ async function handleWebhookStep(run, step2, ctx) {
15902
15903
  try {
15903
- const res = await fetch(step.url, {
15904
- method: step.method ?? "POST",
15904
+ const res = await fetch(step2.url, {
15905
+ method: step2.method ?? "POST",
15905
15906
  headers: { "Content-Type": "application/json" },
15906
- 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) }),
15907
15908
  signal: AbortSignal.timeout(1e4)
15908
15909
  });
15909
15910
  if (!res.ok) throw new Error(`webhook step returned ${res.status}`);
15910
- 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 } });
15911
15912
  } catch (err) {
15912
15913
  const attempts = (run.attemptsForCurrentStep ?? 0) + 1;
15913
15914
  if (attempts >= ctx.config.webhookRetryAttempts) {
15914
- if (step.failureMode === "fail_run") {
15915
+ if (step2.failureMode === "fail_run") {
15915
15916
  await failFlowRun(run, `webhook failed: ${err?.message}`, ctx);
15916
15917
  } else {
15917
15918
  await advanceStep(run, ctx, {
15918
15919
  action: "webhook_called",
15919
- details: { url: step.url, error: err?.message, exhausted: true }
15920
+ details: { url: step2.url, error: err?.message, exhausted: true }
15920
15921
  });
15921
15922
  }
15922
15923
  } else {
@@ -17852,6 +17853,207 @@ function matches(c, f) {
17852
17853
  return true;
17853
17854
  }
17854
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
+
17855
18057
  // src/testing/index.ts
17856
18058
  async function createTestMailer(opts = {}) {
17857
18059
  const { MongoMemoryServer } = await Promise.resolve().then(() => __toESM(require_mongodb_memory_server()));
@@ -17861,13 +18063,13 @@ async function createTestMailer(opts = {}) {
17861
18063
  const db = client.db("mailery-test");
17862
18064
  const memoryAdapter = opts.adapter ? null : new MemoryContactAdapter(opts.seedContacts ?? []);
17863
18065
  const adapter = opts.adapter ?? memoryAdapter;
17864
- const provider = opts.provider ?? new NullProvider();
18066
+ const provider = new RecordingProvider(await resolveProvider(opts.provider ?? "null"));
17865
18067
  const mailer = await Mailer.init({
17866
18068
  db,
17867
18069
  adapter,
17868
- queue: { driver: "noop" },
17869
- providers: { null: provider },
17870
- defaultProvider: "null",
18070
+ queue: opts.queue ?? { driver: "noop" },
18071
+ providers: { [provider.name]: provider },
18072
+ defaultProvider: provider.name,
17871
18073
  publicUrl: "http://localhost:3000",
17872
18074
  unsubscribeSecret: "test-unsub-secret-32-bytes-or-more-please",
17873
18075
  senderAddress: "1 Test St, Brooklyn NY 11201",
@@ -17875,19 +18077,81 @@ async function createTestMailer(opts = {}) {
17875
18077
  workerless: true,
17876
18078
  ...opts.config ?? {}
17877
18079
  });
17878
- const stop = async () => {
17879
- await mailer.stop().catch(() => {
17880
- });
17881
- await client.close().catch(() => {
17882
- });
17883
- await mongo.stop().catch(() => {
17884
- });
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
+ }
17885
18125
  };
17886
- 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
+ });
17887
18144
  }
17888
18145
 
17889
18146
  exports.MemoryContactAdapter = MemoryContactAdapter;
17890
18147
  exports.NullProvider = NullProvider;
18148
+ exports.RecordingProvider = RecordingProvider;
18149
+ exports.buildFlow = buildFlow;
18150
+ exports.buildTemplate = buildTemplate;
17891
18151
  exports.createTestMailer = createTestMailer;
18152
+ exports.dispatchQueued = dispatchQueued;
18153
+ exports.drain = drain;
18154
+ exports.step = step;
18155
+ exports.wrapMjml = wrapMjml;
17892
18156
  //# sourceMappingURL=testing.cjs.map
17893
18157
  //# sourceMappingURL=testing.cjs.map