mailery 0.8.0 → 0.8.1

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
@@ -14434,6 +14434,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
14434
14434
  { key: { dedupeKey: 1 }, unique: true },
14435
14435
  { key: { externalId: 1, occurredAt: -1 } },
14436
14436
  { key: { name: 1, occurredAt: -1 } },
14437
+ { key: { name: 1, createdAt: 1 } },
14437
14438
  { key: { externalId: 1, name: 1 } }
14438
14439
  ]),
14439
14440
  c.flows.createIndexes([
@@ -14444,7 +14445,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
14444
14445
  c.flowRuns.createIndexes([
14445
14446
  { key: { status: 1, nextActionAt: 1 } },
14446
14447
  { key: { externalId: 1, flowId: 1 } },
14447
- { key: { flowId: 1, status: 1 } }
14448
+ { key: { flowId: 1, status: 1 } },
14449
+ {
14450
+ key: { flowId: 1, triggerDedupeKey: 1 },
14451
+ unique: true,
14452
+ partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
14453
+ }
14448
14454
  ]),
14449
14455
  c.templates.createIndexes([
14450
14456
  { key: { slug: 1 }, unique: true },
@@ -14632,7 +14638,13 @@ var BullDriver = class _BullDriver {
14632
14638
  constructor(bull, redis) {
14633
14639
  this.bull = bull;
14634
14640
  this.redis = redis;
14635
- const opts = { connection: redis };
14641
+ const opts = {
14642
+ connection: redis,
14643
+ defaultJobOptions: {
14644
+ removeOnComplete: { age: 24 * 3600, count: 1e3 },
14645
+ removeOnFail: { age: 7 * 24 * 3600 }
14646
+ }
14647
+ };
14636
14648
  this.bullQueues = {
14637
14649
  tick: new bull.Queue(QUEUE_NAMES.tick, opts),
14638
14650
  advance: new bull.Queue(QUEUE_NAMES.advance, opts),
@@ -14650,7 +14662,11 @@ var BullDriver = class _BullDriver {
14650
14662
  await this.bullQueues.tick.upsertJobScheduler(
14651
14663
  "mailer-tick-repeat",
14652
14664
  { every: intervalSeconds * 1e3 },
14653
- { name: "tick", data: {} }
14665
+ {
14666
+ name: "tick",
14667
+ data: {},
14668
+ opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
14669
+ }
14654
14670
  );
14655
14671
  }
14656
14672
  async startWorkers(opts) {
@@ -14760,9 +14776,10 @@ var AgendaDriver = class _AgendaDriver {
14760
14776
  "mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
14761
14777
  );
14762
14778
  }
14779
+ const collectionName = opts.collectionName ?? "_mailerJobs";
14763
14780
  const backend = new backendMod.MongoBackend({
14764
14781
  mongo: opts.db,
14765
- collection: opts.collectionName ?? "_mailerJobs"
14782
+ collection: collectionName
14766
14783
  });
14767
14784
  const agenda = new agendaMod.Agenda({
14768
14785
  backend,
@@ -14771,13 +14788,15 @@ var AgendaDriver = class _AgendaDriver {
14771
14788
  maxConcurrency: 50,
14772
14789
  defaultConcurrency: 5
14773
14790
  });
14774
- return new _AgendaDriver(agenda, agendaMod, opts.db);
14791
+ return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
14775
14792
  }
14776
14793
  db;
14777
- constructor(agenda, agendaMod, db) {
14794
+ collName;
14795
+ constructor(agenda, agendaMod, db, collectionName) {
14778
14796
  this.agenda = agenda;
14779
14797
  this.agendaMod = agendaMod;
14780
14798
  this.db = db;
14799
+ this.collName = collectionName;
14781
14800
  this.queues = {
14782
14801
  tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
14783
14802
  advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
@@ -14810,10 +14829,7 @@ var AgendaDriver = class _AgendaDriver {
14810
14829
  }
14811
14830
  /** Direct access to the Mongo collection Agenda persists jobs into. */
14812
14831
  jobsCollection() {
14813
- return this.db.collection(this.collectionName());
14814
- }
14815
- collectionName() {
14816
- return "_mailerJobs";
14832
+ return this.db.collection(this.collName);
14817
14833
  }
14818
14834
  async findPending(name, jobId) {
14819
14835
  return this.jobsCollection().findOne({
@@ -14823,12 +14839,6 @@ var AgendaDriver = class _AgendaDriver {
14823
14839
  });
14824
14840
  }
14825
14841
  async scheduleRepeatingTick(intervalSeconds) {
14826
- if (!this.started) {
14827
- this.agenda.define(QUEUE_NAMES2.tick, async () => {
14828
- }, { concurrency: 1 });
14829
- await this.agenda.start();
14830
- this.started = true;
14831
- }
14832
14842
  await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
14833
14843
  }
14834
14844
  async startWorkers(opts) {
@@ -14931,6 +14941,7 @@ async function createQueueDriver(config, fallbackDb) {
14931
14941
 
14932
14942
  // src/server/runner/triggers.ts
14933
14943
  var BATCH_SIZE = 1e3;
14944
+ var SCAN_OVERLAP_MS = 3e4;
14934
14945
  async function processNewlyFiredEventTriggers(ctx) {
14935
14946
  const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
14936
14947
  for (const flow of flows) {
@@ -14941,16 +14952,19 @@ async function processFlowTriggers(flow, ctx) {
14941
14952
  const eventName = flow.trigger.eventName;
14942
14953
  if (!eventName) return;
14943
14954
  const since = flow.lastTriggerScanAt ?? flow.createdAt;
14944
- const events = await ctx.collections.events.find({ name: eventName, occurredAt: { $gt: since } }).sort({ occurredAt: 1 }).limit(BATCH_SIZE).toArray();
14955
+ const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
14956
+ const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
14945
14957
  if (events.length === 0) return;
14946
14958
  for (const event of events) {
14947
14959
  await tryEnterFlow(flow, event, ctx);
14948
14960
  }
14949
- const newestOccurredAt = events[events.length - 1].occurredAt;
14950
- await ctx.collections.flows.updateOne(
14951
- { _id: flow._id },
14952
- { $set: { lastTriggerScanAt: newestOccurredAt, updatedAt: /* @__PURE__ */ new Date() } }
14953
- );
14961
+ const newestCreatedAt = events[events.length - 1].createdAt;
14962
+ if (newestCreatedAt.getTime() > since.getTime()) {
14963
+ await ctx.collections.flows.updateOne(
14964
+ { _id: flow._id },
14965
+ { $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
14966
+ );
14967
+ }
14954
14968
  }
14955
14969
  async function tryEnterFlow(flow, event, ctx) {
14956
14970
  if (flow.trigger.once) {
@@ -14962,120 +14976,456 @@ async function tryEnterFlow(flow, event, ctx) {
14962
14976
  }
14963
14977
  const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
14964
14978
  if (!sub || sub.status !== "subscribed") return;
14965
- const result = await ctx.collections.flowRuns.insertOne({
14966
- externalId: event.externalId,
14967
- flowId: flow._id,
14968
- flowSlug: flow.slug,
14969
- flowVersion: flow.version,
14970
- emailAtEntry: sub.emailAtSubscribe,
14971
- triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14972
- enteredAt: /* @__PURE__ */ new Date(),
14973
- status: "active",
14974
- currentStepIndex: 0,
14975
- currentBranchPath: [],
14976
- nextActionAt: /* @__PURE__ */ new Date(),
14977
- attemptsForCurrentStep: 0,
14978
- history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
14979
- exitedAt: null,
14980
- exitReason: null,
14981
- createdAt: /* @__PURE__ */ new Date(),
14982
- updatedAt: /* @__PURE__ */ new Date()
14983
- });
14979
+ let result;
14980
+ try {
14981
+ result = await ctx.collections.flowRuns.insertOne({
14982
+ externalId: event.externalId,
14983
+ flowId: flow._id,
14984
+ flowSlug: flow.slug,
14985
+ flowVersion: flow.version,
14986
+ emailAtEntry: sub.emailAtSubscribe,
14987
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14988
+ triggerDedupeKey: event.dedupeKey,
14989
+ enteredAt: /* @__PURE__ */ new Date(),
14990
+ status: "active",
14991
+ currentStepIndex: 0,
14992
+ currentBranchPath: [],
14993
+ nextActionAt: /* @__PURE__ */ new Date(),
14994
+ attemptsForCurrentStep: 0,
14995
+ history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
14996
+ exitedAt: null,
14997
+ exitReason: null,
14998
+ createdAt: /* @__PURE__ */ new Date(),
14999
+ updatedAt: /* @__PURE__ */ new Date()
15000
+ });
15001
+ } catch (err) {
15002
+ if (err?.code !== 11e3) throw err;
15003
+ return;
15004
+ }
14984
15005
  await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
14985
15006
  }
14986
15007
 
14987
- // src/server/runner/predicate.ts
14988
- var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
14989
- async function evaluatePredicate(predicate, ctx) {
14990
- const p = predicate;
14991
- if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
14992
- if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
14993
- if ("fieldEquals" in p) {
14994
- return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
14995
- }
14996
- if ("fieldExists" in p) {
14997
- return ctx.contact.fields[p.fieldExists] !== void 0;
14998
- }
14999
- if ("subscriptionStatus" in p) {
15000
- const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
15001
- return sub?.status === p.subscriptionStatus;
15002
- }
15003
- if ("hasFiredEvent" in p) {
15004
- return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
15005
- }
15006
- if ("notHasFiredEvent" in p) {
15007
- return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
15008
- }
15009
- if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
15010
- if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
15011
- if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
15012
- if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
15013
- if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
15014
- if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
15015
- if ("all" in p) {
15016
- for (const sub of p.all) {
15017
- if (!await evaluatePredicate(sub, ctx)) return false;
15018
- }
15019
- return true;
15020
- }
15021
- if ("any" in p) {
15022
- for (const sub of p.any) {
15023
- if (await evaluatePredicate(sub, ctx)) return true;
15008
+ // src/server/templates/sender-domain.ts
15009
+ function extractDomain(email) {
15010
+ if (typeof email !== "string") return null;
15011
+ const at = email.lastIndexOf("@");
15012
+ if (at <= 0 || at === email.length - 1) return null;
15013
+ return email.slice(at + 1).toLowerCase().trim();
15014
+ }
15015
+
15016
+ // src/server/runner/health.ts
15017
+ var ZERO_COUNTERS = {
15018
+ sent: 0,
15019
+ delivered: 0,
15020
+ bounced: 0,
15021
+ hardBounced: 0,
15022
+ softBounced: 0,
15023
+ complained: 0,
15024
+ failedToSend: 0
15025
+ };
15026
+ var ZERO_RATES = {
15027
+ bounceRate: 0,
15028
+ hardBounceRate: 0,
15029
+ complaintRate: 0,
15030
+ failureRate: 0
15031
+ };
15032
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15033
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
15034
+ const writes = [];
15035
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
15036
+ if (dims) {
15037
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
15038
+ if (domain) {
15039
+ const id = healthBucketId(domain, dims.kind);
15040
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
15024
15041
  }
15025
- return false;
15026
- }
15027
- if ("not" in p) {
15028
- return !await evaluatePredicate(p.not, ctx);
15029
15042
  }
15030
- return false;
15043
+ await Promise.all(writes);
15031
15044
  }
15032
- async function hasEvent(ctx, name, opts) {
15033
- const filter = { externalId: ctx.contact.externalId, name };
15034
- const lower = effectiveLowerBound(ctx, opts);
15035
- if (lower) filter.occurredAt = { $gt: lower };
15036
- const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
15037
- return !!found;
15045
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
15046
+ await ctx.collections.health.updateOne(
15047
+ { _id },
15048
+ {
15049
+ $inc: { [`counters.${counter2}`]: by },
15050
+ $setOnInsert: {
15051
+ _id,
15052
+ senderDomain,
15053
+ kind,
15054
+ windowStartedAt: /* @__PURE__ */ new Date(),
15055
+ windowDurationMs: windowMs,
15056
+ status: "healthy",
15057
+ trippedAt: null,
15058
+ trippedReason: null,
15059
+ manuallyResumedAt: null,
15060
+ rates: { ...ZERO_RATES }
15061
+ },
15062
+ $set: { updatedAt: /* @__PURE__ */ new Date() }
15063
+ },
15064
+ { upsert: true }
15065
+ );
15038
15066
  }
15039
- async function openOrClickCount(ctx, kind, opts, excludeBots) {
15040
- const filter = { externalId: ctx.contact.externalId };
15041
- if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
15042
- if (kind === "opened") filter.openedAt = { $ne: null };
15043
- if (kind === "clicked") filter.firstClickAt = { $ne: null };
15044
- const lower = effectiveLowerBound(ctx, opts);
15045
- if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
15046
- if (!excludeBots) {
15047
- return await ctx.collections.sends.countDocuments(filter);
15067
+ async function evaluateHealth(ctx) {
15068
+ const cb = ctx.config.circuitBreaker;
15069
+ const windowMs = cb.windowMinutes * 60 * 1e3;
15070
+ const docs = await ctx.collections.health.find({}).toArray();
15071
+ if (docs.length === 0) return;
15072
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
15073
+ if (!hasAgg) {
15074
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
15048
15075
  }
15049
- const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
15050
- let n = 0;
15051
- for (const s of docs) {
15052
- if (kind === "opened") {
15053
- n++;
15076
+ for (const doc of docs) {
15077
+ const isAgg = doc._id === HEALTH_AGG_ID;
15078
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15079
+ if (windowAge > windowMs && doc.status !== "tripped") {
15080
+ await ctx.collections.health.updateOne(
15081
+ { _id: doc._id },
15082
+ {
15083
+ $set: {
15084
+ windowStartedAt: /* @__PURE__ */ new Date(),
15085
+ windowDurationMs: windowMs,
15086
+ counters: { ...ZERO_COUNTERS },
15087
+ rates: { ...ZERO_RATES },
15088
+ status: "healthy",
15089
+ updatedAt: /* @__PURE__ */ new Date()
15090
+ }
15091
+ }
15092
+ );
15054
15093
  continue;
15055
15094
  }
15056
- const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
15057
- if (hasHumanClick) n++;
15095
+ const c = doc.counters;
15096
+ const total = c.sent || 1;
15097
+ const rates = {
15098
+ bounceRate: c.bounced / total,
15099
+ hardBounceRate: c.hardBounced / total,
15100
+ complaintRate: c.complained / total,
15101
+ failureRate: c.failedToSend / total
15102
+ };
15103
+ await ctx.collections.health.updateOne(
15104
+ { _id: doc._id },
15105
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15106
+ );
15107
+ if (isAgg) continue;
15108
+ if (c.sent < cb.minSendsBeforeEval) continue;
15109
+ if (doc.status === "tripped") continue;
15110
+ let trippedReason = null;
15111
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15112
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15113
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15114
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15115
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15116
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15117
+ }
15118
+ if (trippedReason) {
15119
+ const result = await ctx.collections.health.updateOne(
15120
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
15121
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15122
+ );
15123
+ if (result.modifiedCount > 0) {
15124
+ if (ctx.audit) {
15125
+ try {
15126
+ await ctx.audit({
15127
+ actor: "system:circuit-breaker",
15128
+ action: "health.trip",
15129
+ resource: {
15130
+ collection: "mailer_health",
15131
+ id: String(doc._id),
15132
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
15133
+ },
15134
+ diffSummary: trippedReason
15135
+ });
15136
+ } catch {
15137
+ }
15138
+ }
15139
+ if (ctx.config.onCircuitBreakerTrip) {
15140
+ try {
15141
+ await ctx.config.onCircuitBreakerTrip({
15142
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
15143
+ rates
15144
+ });
15145
+ } catch {
15146
+ }
15147
+ }
15148
+ }
15149
+ continue;
15150
+ }
15151
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15152
+ if (doc.status !== "degraded") {
15153
+ await ctx.collections.health.updateOne(
15154
+ { _id: doc._id },
15155
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15156
+ );
15157
+ }
15158
+ } else if (doc.status === "degraded") {
15159
+ await ctx.collections.health.updateOne(
15160
+ { _id: doc._id },
15161
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15162
+ );
15163
+ }
15058
15164
  }
15059
- return n;
15060
15165
  }
15061
- function effectiveLowerBound(ctx, opts) {
15062
- const now = ctx.now ?? /* @__PURE__ */ new Date();
15063
- if (opts.sinceFlowStart) return ctx.run.enteredAt;
15064
- if (opts.withinDays && opts.withinDays > 0) {
15065
- return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
15066
- }
15067
- return null;
15166
+ async function getBucketStatus(ctx, fromEmail, kind) {
15167
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
15168
+ const id = healthBucketId(domain, kind);
15169
+ return ctx.collections.health.findOne({ _id: id });
15068
15170
  }
15069
15171
 
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);
15172
+ // src/server/runner/webhook.ts
15173
+ function dimsFromSend(send) {
15174
+ if (!send) return null;
15175
+ return { fromEmail: send.fromEmail, kind: send.kind };
15176
+ }
15177
+ async function processWebhookBacklog(ctx, opts = {}) {
15178
+ const filter = { processed: false };
15179
+ if (opts.olderThanMs) {
15180
+ filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
15181
+ }
15182
+ const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
15183
+ for (const evt of batch) {
15184
+ const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
15185
+ { _id: evt._id, processed: false },
15186
+ { $set: { processed: true } }
15187
+ );
15188
+ if (!claimed) continue;
15189
+ try {
15190
+ const normalized = evt.raw?.normalized;
15191
+ const details = normalized?.details ?? {};
15192
+ await applyWebhookEvent(
15193
+ {
15194
+ type: evt.normalizedType,
15195
+ providerEventId: evt.providerEventId,
15196
+ providerMessageId: evt.providerMessageId,
15197
+ email: evt.email,
15198
+ occurredAt: evt.occurredAt,
15199
+ details
15200
+ },
15201
+ ctx
15202
+ );
15203
+ } catch (err) {
15204
+ console.error("mailery: webhook apply failed", { id: String(evt._id), err });
15205
+ }
15206
+ }
15207
+ }
15208
+ async function applyWebhookEvent(event, ctx) {
15209
+ const send = await ctx.collections.sends.findOne(
15210
+ event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
15211
+ { sort: { queuedAt: -1 } }
15212
+ );
15213
+ switch (event.type) {
15214
+ case "delivered":
15215
+ if (send) {
15216
+ await ctx.collections.sends.updateOne(
15217
+ { _id: send._id },
15218
+ { $set: { status: "delivered", deliveredAt: event.occurredAt } }
15219
+ );
15220
+ }
15221
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
15222
+ break;
15223
+ case "open":
15224
+ if (send) {
15225
+ await ctx.collections.sends.updateOne(
15226
+ { _id: send._id },
15227
+ {
15228
+ $set: {
15229
+ openedAt: send.openedAt ?? event.occurredAt,
15230
+ status: send.status === "sent" ? "delivered" : send.status
15231
+ },
15232
+ $inc: { openCount: 1 }
15233
+ }
15234
+ );
15235
+ }
15236
+ break;
15237
+ case "click":
15238
+ if (send) {
15239
+ await ctx.collections.sends.updateOne(
15240
+ { _id: send._id },
15241
+ {
15242
+ $set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
15243
+ $inc: { clickCount: 1 },
15244
+ $push: {
15245
+ clickedLinks: {
15246
+ url: event.details.clickedUrl ?? "",
15247
+ linkId: "",
15248
+ clickedAt: event.occurredAt
15249
+ }
15250
+ }
15251
+ }
15252
+ );
15253
+ }
15254
+ break;
15255
+ case "bounce": {
15256
+ const bounceType = event.details.bounceType ?? "hard";
15257
+ if (send) {
15258
+ await ctx.collections.sends.updateOne(
15259
+ { _id: send._id },
15260
+ {
15261
+ $set: {
15262
+ status: "bounced",
15263
+ bounceType,
15264
+ bounceReason: event.details.bounceReason ?? null
15265
+ }
15266
+ }
15267
+ );
15268
+ }
15269
+ if (bounceType === "hard") {
15270
+ await suppressOnce(ctx, event.email, "hard_bounce", "all");
15271
+ await ctx.collections.subscriptions.updateOne(
15272
+ { emailAtSubscribe: event.email },
15273
+ { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
15274
+ );
15275
+ }
15276
+ {
15277
+ const dims = dimsFromSend(send);
15278
+ await recordHealthCounter(ctx, "bounced", dims);
15279
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
15280
+ }
15281
+ break;
15282
+ }
15283
+ case "complaint":
15284
+ case "spam_report":
15285
+ if (send) {
15286
+ await ctx.collections.sends.updateOne(
15287
+ { _id: send._id },
15288
+ { $set: { complainedAt: event.occurredAt, status: "complained" } }
15289
+ );
15290
+ }
15291
+ await suppressOnce(ctx, event.email, "complaint", "all");
15292
+ await ctx.collections.subscriptions.updateOne(
15293
+ { emailAtSubscribe: event.email },
15294
+ { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
15295
+ );
15296
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
15297
+ break;
15298
+ case "unsubscribe":
15299
+ if (send) {
15300
+ await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
15301
+ }
15302
+ await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
15303
+ await ctx.collections.subscriptions.updateOne(
15304
+ { emailAtSubscribe: event.email },
15305
+ {
15306
+ $set: {
15307
+ status: "unsubscribed",
15308
+ unsubscribedAt: event.occurredAt,
15309
+ unsubscribeReason: "user_request",
15310
+ updatedAt: /* @__PURE__ */ new Date()
15311
+ }
15312
+ }
15313
+ );
15314
+ break;
15315
+ }
15316
+ }
15317
+ async function suppressOnce(ctx, email, reason, scope) {
15318
+ const normalized = email.toLowerCase();
15319
+ await ctx.collections.suppressions.updateOne(
15320
+ { email: normalized, scope },
15321
+ {
15322
+ $setOnInsert: {
15323
+ email: normalized,
15324
+ emailHash: sha256Hex(normalized),
15325
+ scope,
15326
+ reason,
15327
+ source: "provider_webhook",
15328
+ notes: null,
15329
+ addedAt: /* @__PURE__ */ new Date(),
15330
+ expiresAt: null
15331
+ }
15332
+ },
15333
+ { upsert: true }
15334
+ );
15335
+ }
15336
+
15337
+ // src/server/runner/predicate.ts
15338
+ var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
15339
+ async function evaluatePredicate(predicate, ctx) {
15340
+ const p = predicate;
15341
+ if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
15342
+ if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
15343
+ if ("fieldEquals" in p) {
15344
+ return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
15345
+ }
15346
+ if ("fieldExists" in p) {
15347
+ return ctx.contact.fields[p.fieldExists] !== void 0;
15348
+ }
15349
+ if ("subscriptionStatus" in p) {
15350
+ const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
15351
+ return sub?.status === p.subscriptionStatus;
15352
+ }
15353
+ if ("hasFiredEvent" in p) {
15354
+ return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
15355
+ }
15356
+ if ("notHasFiredEvent" in p) {
15357
+ return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
15358
+ }
15359
+ if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
15360
+ if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
15361
+ if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
15362
+ if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
15363
+ if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
15364
+ if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
15365
+ if ("all" in p) {
15366
+ for (const sub of p.all) {
15367
+ if (!await evaluatePredicate(sub, ctx)) return false;
15368
+ }
15369
+ return true;
15370
+ }
15371
+ if ("any" in p) {
15372
+ for (const sub of p.any) {
15373
+ if (await evaluatePredicate(sub, ctx)) return true;
15374
+ }
15375
+ return false;
15376
+ }
15377
+ if ("not" in p) {
15378
+ return !await evaluatePredicate(p.not, ctx);
15379
+ }
15380
+ return false;
15381
+ }
15382
+ async function hasEvent(ctx, name, opts) {
15383
+ const filter = { externalId: ctx.contact.externalId, name };
15384
+ const lower = effectiveLowerBound(ctx, opts);
15385
+ if (lower) filter.occurredAt = { $gt: lower };
15386
+ const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
15387
+ return !!found;
15388
+ }
15389
+ async function openOrClickCount(ctx, kind, opts, excludeBots) {
15390
+ const filter = { externalId: ctx.contact.externalId };
15391
+ if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
15392
+ if (kind === "opened") filter.openedAt = { $ne: null };
15393
+ if (kind === "clicked") filter.firstClickAt = { $ne: null };
15394
+ const lower = effectiveLowerBound(ctx, opts);
15395
+ if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
15396
+ if (!excludeBots) {
15397
+ return await ctx.collections.sends.countDocuments(filter);
15398
+ }
15399
+ const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
15400
+ let n = 0;
15401
+ for (const s of docs) {
15402
+ if (kind === "opened") {
15403
+ n++;
15404
+ continue;
15405
+ }
15406
+ const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
15407
+ if (hasHumanClick) n++;
15408
+ }
15409
+ return n;
15410
+ }
15411
+ function effectiveLowerBound(ctx, opts) {
15412
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
15413
+ if (opts.sinceFlowStart) return ctx.run.enteredAt;
15414
+ if (opts.withinDays && opts.withinDays > 0) {
15415
+ return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
15416
+ }
15417
+ return null;
15418
+ }
15419
+
15420
+ // src/server/runner/delivery-window.ts
15421
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
15422
+ function computeDeliveryTime(now, window2, contactTimezone) {
15423
+ const tz = pickTimezone(window2, contactTimezone);
15424
+ let candidate = now;
15425
+ if (window2.timeOfDay) {
15426
+ const [hh, mm] = window2.timeOfDay.split(":").map(Number);
15427
+ const local = localParts(candidate, tz);
15428
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
15079
15429
  if (candidate.getTime() < todaySlot.getTime()) {
15080
15430
  candidate = todaySlot;
15081
15431
  } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
@@ -15207,7 +15557,8 @@ function applyTracking(html, opts) {
15207
15557
  const seen = /* @__PURE__ */ new Map();
15208
15558
  let out = html;
15209
15559
  if (opts.trackClicks) {
15210
- out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, url, post) => {
15560
+ out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
15561
+ const url = decodeHtmlEntities(rawUrl);
15211
15562
  if (shouldSkipClickRewrite(url, preserve, full)) return full;
15212
15563
  let linkId = seen.get(url);
15213
15564
  if (!linkId) {
@@ -15239,236 +15590,95 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
15239
15590
  if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
15240
15591
  return false;
15241
15592
  }
15242
- function shortHash(input) {
15243
- return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
15244
- }
15245
- function makeHandlebars(extra) {
15246
- const hb = Handlebars.create();
15247
- hb.registerHelper("eq", (a, b) => a === b);
15248
- hb.registerHelper("ne", (a, b) => a !== b);
15249
- hb.registerHelper("gt", (a, b) => a > b);
15250
- hb.registerHelper("lt", (a, b) => a < b);
15251
- hb.registerHelper("gte", (a, b) => a >= b);
15252
- hb.registerHelper("lte", (a, b) => a <= b);
15253
- hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
15254
- hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
15255
- hb.registerHelper("not", (a) => !a);
15256
- hb.registerHelper("formatDate", (value, fmt) => {
15257
- if (!value) return "";
15258
- const d = value instanceof Date ? value : new Date(String(value));
15259
- if (Number.isNaN(d.getTime())) return "";
15260
- if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
15261
- if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
15262
- return d.toISOString().slice(0, 10);
15263
- });
15264
- hb.registerHelper("formatNumber", (n) => {
15265
- const v = Number(n);
15266
- return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
15267
- });
15268
- hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
15269
- const v = Number(cents);
15270
- if (!Number.isFinite(v)) return "";
15271
- return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
15272
- });
15273
- hb.registerHelper(
15274
- "pluralize",
15275
- (n, one, many) => Number(n) === 1 ? one : many
15276
- );
15277
- if (extra) {
15278
- for (const [name, fn] of Object.entries(extra)) {
15279
- hb.registerHelper(name, fn);
15280
- }
15281
- }
15282
- return hb;
15283
- }
15284
-
15285
- // src/server/runner/send.ts
15286
- init_vars();
15287
-
15288
- // src/server/runner/suppression.ts
15289
- var SCOPES_BY_KIND = {
15290
- marketing: ["all", "marketing"],
15291
- transactional: ["all", "transactional"]
15593
+ var NAMED_ENTITIES = {
15594
+ amp: "&",
15595
+ lt: "<",
15596
+ gt: ">",
15597
+ quot: '"',
15598
+ apos: "'",
15599
+ nbsp: "\xA0"
15292
15600
  };
15293
- async function isSuppressed(collections, email, kind) {
15294
- const normalized = email.toLowerCase();
15295
- const allowed = SCOPES_BY_KIND[kind];
15296
- const byEmail = await collections.suppressions.findOne({
15297
- email: normalized,
15298
- scope: { $in: allowed },
15299
- $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
15300
- });
15301
- if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
15302
- const hashed = await collections.suppressions.findOne({
15303
- emailHash: sha256Hex(normalized),
15304
- scope: { $in: allowed }
15305
- });
15306
- if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
15307
- return { suppressed: false };
15308
- }
15309
-
15310
- // src/server/templates/sender-domain.ts
15311
- function extractDomain(email) {
15312
- if (typeof email !== "string") return null;
15313
- const at = email.lastIndexOf("@");
15314
- if (at <= 0 || at === email.length - 1) return null;
15315
- return email.slice(at + 1).toLowerCase().trim();
15316
- }
15317
-
15318
- // src/server/runner/health.ts
15319
- var ZERO_COUNTERS = {
15320
- sent: 0,
15321
- delivered: 0,
15322
- bounced: 0,
15323
- hardBounced: 0,
15324
- softBounced: 0,
15325
- complained: 0,
15326
- failedToSend: 0
15327
- };
15328
- var ZERO_RATES = {
15329
- bounceRate: 0,
15330
- hardBounceRate: 0,
15331
- complaintRate: 0,
15332
- failureRate: 0
15333
- };
15334
- async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15335
- const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
15336
- const writes = [];
15337
- writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
15338
- if (dims) {
15339
- const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
15340
- if (domain) {
15341
- const id = healthBucketId(domain, dims.kind);
15342
- writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
15343
- }
15344
- }
15345
- await Promise.all(writes);
15346
- }
15347
- async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
15348
- await ctx.collections.health.updateOne(
15349
- { _id },
15350
- {
15351
- $inc: { [`counters.${counter2}`]: by },
15352
- $setOnInsert: {
15353
- _id,
15354
- senderDomain,
15355
- kind,
15356
- windowStartedAt: /* @__PURE__ */ new Date(),
15357
- windowDurationMs: windowMs,
15358
- status: "healthy",
15359
- trippedAt: null,
15360
- trippedReason: null,
15361
- manuallyResumedAt: null,
15362
- rates: { ...ZERO_RATES }
15363
- },
15364
- $set: { updatedAt: /* @__PURE__ */ new Date() }
15365
- },
15366
- { upsert: true }
15367
- );
15368
- }
15369
- async function evaluateHealth(ctx) {
15370
- const cb = ctx.config.circuitBreaker;
15371
- const windowMs = cb.windowMinutes * 60 * 1e3;
15372
- const docs = await ctx.collections.health.find({}).toArray();
15373
- if (docs.length === 0) return;
15374
- const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
15375
- if (!hasAgg) {
15376
- await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
15377
- }
15378
- for (const doc of docs) {
15379
- const isAgg = doc._id === HEALTH_AGG_ID;
15380
- const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15381
- if (windowAge > windowMs && doc.status !== "tripped") {
15382
- await ctx.collections.health.updateOne(
15383
- { _id: doc._id },
15384
- {
15385
- $set: {
15386
- windowStartedAt: /* @__PURE__ */ new Date(),
15387
- windowDurationMs: windowMs,
15388
- counters: { ...ZERO_COUNTERS },
15389
- rates: { ...ZERO_RATES },
15390
- status: "healthy",
15391
- updatedAt: /* @__PURE__ */ new Date()
15392
- }
15393
- }
15394
- );
15395
- continue;
15396
- }
15397
- const c = doc.counters;
15398
- const total = c.sent || 1;
15399
- const rates = {
15400
- bounceRate: c.bounced / total,
15401
- hardBounceRate: c.hardBounced / total,
15402
- complaintRate: c.complained / total,
15403
- failureRate: c.failedToSend / total
15404
- };
15405
- await ctx.collections.health.updateOne(
15406
- { _id: doc._id },
15407
- { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15408
- );
15409
- if (isAgg) continue;
15410
- if (c.sent < cb.minSendsBeforeEval) continue;
15411
- if (doc.status === "tripped") continue;
15412
- let trippedReason = null;
15413
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15414
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15415
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15416
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15417
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15418
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15419
- }
15420
- if (trippedReason) {
15421
- const result = await ctx.collections.health.updateOne(
15422
- { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
15423
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15424
- );
15425
- if (result.modifiedCount > 0) {
15426
- if (ctx.audit) {
15427
- try {
15428
- await ctx.audit({
15429
- actor: "system:circuit-breaker",
15430
- action: "health.trip",
15431
- resource: {
15432
- collection: "mailer_health",
15433
- id: String(doc._id),
15434
- slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
15435
- },
15436
- diffSummary: trippedReason
15437
- });
15438
- } catch {
15439
- }
15440
- }
15441
- if (ctx.config.onCircuitBreakerTrip) {
15442
- try {
15443
- await ctx.config.onCircuitBreakerTrip({
15444
- reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
15445
- rates
15446
- });
15447
- } catch {
15448
- }
15449
- }
15601
+ function decodeHtmlEntities(input) {
15602
+ return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
15603
+ if (body[0] === "#") {
15604
+ const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
15605
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
15606
+ try {
15607
+ return String.fromCodePoint(code);
15608
+ } catch {
15609
+ return match;
15450
15610
  }
15451
- continue;
15452
15611
  }
15453
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15454
- if (doc.status !== "degraded") {
15455
- await ctx.collections.health.updateOne(
15456
- { _id: doc._id },
15457
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15458
- );
15459
- }
15460
- } else if (doc.status === "degraded") {
15461
- await ctx.collections.health.updateOne(
15462
- { _id: doc._id },
15463
- { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15464
- );
15612
+ const named = NAMED_ENTITIES[body.toLowerCase()];
15613
+ return named ?? match;
15614
+ });
15615
+ }
15616
+ function shortHash(input) {
15617
+ return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
15618
+ }
15619
+ function makeHandlebars(extra) {
15620
+ const hb = Handlebars.create();
15621
+ hb.registerHelper("eq", (a, b) => a === b);
15622
+ hb.registerHelper("ne", (a, b) => a !== b);
15623
+ hb.registerHelper("gt", (a, b) => a > b);
15624
+ hb.registerHelper("lt", (a, b) => a < b);
15625
+ hb.registerHelper("gte", (a, b) => a >= b);
15626
+ hb.registerHelper("lte", (a, b) => a <= b);
15627
+ hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
15628
+ hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
15629
+ hb.registerHelper("not", (a) => !a);
15630
+ hb.registerHelper("formatDate", (value, fmt) => {
15631
+ if (!value) return "";
15632
+ const d = value instanceof Date ? value : new Date(String(value));
15633
+ if (Number.isNaN(d.getTime())) return "";
15634
+ if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
15635
+ if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
15636
+ return d.toISOString().slice(0, 10);
15637
+ });
15638
+ hb.registerHelper("formatNumber", (n) => {
15639
+ const v = Number(n);
15640
+ return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
15641
+ });
15642
+ hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
15643
+ const v = Number(cents);
15644
+ if (!Number.isFinite(v)) return "";
15645
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
15646
+ });
15647
+ hb.registerHelper(
15648
+ "pluralize",
15649
+ (n, one, many) => Number(n) === 1 ? one : many
15650
+ );
15651
+ if (extra) {
15652
+ for (const [name, fn] of Object.entries(extra)) {
15653
+ hb.registerHelper(name, fn);
15465
15654
  }
15466
15655
  }
15656
+ return hb;
15467
15657
  }
15468
- async function getBucketStatus(ctx, fromEmail, kind) {
15469
- const domain = fromEmail ? extractDomain(fromEmail) : null;
15470
- const id = healthBucketId(domain, kind);
15471
- return ctx.collections.health.findOne({ _id: id });
15658
+
15659
+ // src/server/runner/send.ts
15660
+ init_vars();
15661
+
15662
+ // src/server/runner/suppression.ts
15663
+ var SCOPES_BY_KIND = {
15664
+ marketing: ["all", "marketing"],
15665
+ transactional: ["all", "transactional"]
15666
+ };
15667
+ async function isSuppressed(collections, email, kind) {
15668
+ const normalized = email.toLowerCase();
15669
+ const allowed = SCOPES_BY_KIND[kind];
15670
+ const byEmail = await collections.suppressions.findOne({
15671
+ email: normalized,
15672
+ scope: { $in: allowed },
15673
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
15674
+ });
15675
+ if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
15676
+ const hashed = await collections.suppressions.findOne({
15677
+ emailHash: sha256Hex(normalized),
15678
+ scope: { $in: allowed }
15679
+ });
15680
+ if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
15681
+ return { suppressed: false };
15472
15682
  }
15473
15683
 
15474
15684
  // src/server/runner/send.ts
@@ -15536,9 +15746,11 @@ async function handleSend(run, step2, contact, flow, ctx) {
15536
15746
  await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
15537
15747
  }
15538
15748
  async function dispatchSend(sendId, ctx) {
15539
- const send = await ctx.collections.sends.findOne({ _id: sendId });
15749
+ const send = await ctx.collections.sends.findOneAndUpdate(
15750
+ { _id: sendId, status: { $in: ["queued", "failed"] } },
15751
+ { $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
15752
+ );
15540
15753
  if (!send) return;
15541
- if (send.status !== "queued" && send.status !== "failed") return;
15542
15754
  const template = await ctx.collections.templates.findOne({ _id: send.templateId });
15543
15755
  if (!template) {
15544
15756
  await markFailed(send._id, "template_missing", ctx);
@@ -15555,6 +15767,10 @@ async function dispatchSend(sendId, ctx) {
15555
15767
  if (send.kind === "marketing") {
15556
15768
  const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
15557
15769
  if (bucket?.status === "tripped") {
15770
+ await ctx.collections.sends.updateOne(
15771
+ { _id: send._id },
15772
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
15773
+ );
15558
15774
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
15559
15775
  return;
15560
15776
  }
@@ -15803,7 +16019,7 @@ async function handleWait(run, step2, ctx) {
15803
16019
  await ctx.queues.advance.add(
15804
16020
  "advance",
15805
16021
  { flowRunId: String(run._id) },
15806
- { delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
16022
+ { delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
15807
16023
  );
15808
16024
  }
15809
16025
  async function handleCondition(run, step2, contact, ctx) {
@@ -15915,7 +16131,7 @@ async function handleWebhookStep(run, step2, ctx) {
15915
16131
  await ctx.queues.advance.add(
15916
16132
  "advance",
15917
16133
  { flowRunId: String(run._id) },
15918
- { delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
16134
+ { delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
15919
16135
  );
15920
16136
  }
15921
16137
  }
@@ -15944,7 +16160,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
15944
16160
  { flowRunId: String(run._id) },
15945
16161
  {
15946
16162
  delay: Math.max(0, deliverAt.getTime() - Date.now()),
15947
- jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
16163
+ jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
15948
16164
  }
15949
16165
  );
15950
16166
  }
@@ -15999,13 +16215,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
15999
16215
  let arr = steps;
16000
16216
  for (let i = 0; i < branchPath.length; i += 3) {
16001
16217
  const parentIndex = branchPath[i];
16002
- const branchKey = branchPath[i + 1];
16218
+ const branchKey2 = branchPath[i + 1];
16003
16219
  const parent = arr[parentIndex];
16004
16220
  if (!parent || parent.type !== "branch") return null;
16005
- arr = branchKey === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
16221
+ arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
16006
16222
  }
16007
16223
  return arr[currentStepIndex] ?? null;
16008
16224
  }
16225
+ function branchKey(run) {
16226
+ return run.currentBranchPath.join("_");
16227
+ }
16009
16228
  function unitToMs(value, unit) {
16010
16229
  const m = 6e4;
16011
16230
  switch (unit) {
@@ -16032,6 +16251,7 @@ async function sweepStrandedFlowRuns(ctx) {
16032
16251
  }
16033
16252
  }
16034
16253
  }
16254
+ var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
16035
16255
  async function processScheduledBroadcasts(ctx) {
16036
16256
  const now = /* @__PURE__ */ new Date();
16037
16257
  const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
@@ -16042,15 +16262,49 @@ async function processScheduledBroadcasts(ctx) {
16042
16262
  { returnDocument: "after" }
16043
16263
  );
16044
16264
  if (!claimed) continue;
16045
- try {
16046
- await dispatchBroadcast(claimed, ctx);
16047
- } catch (err) {
16048
- console.error("mailery: broadcast dispatch failed", { id: String(b._id), err });
16049
- await ctx.collections.broadcasts.updateOne(
16050
- { _id: b._id },
16051
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
16052
- );
16265
+ await startBroadcastDispatch(claimed, ctx);
16266
+ }
16267
+ }
16268
+ async function startBroadcastDispatch(broadcast, ctx) {
16269
+ if (ctx.config.queue.driver === "noop") {
16270
+ await runBroadcastDispatch(broadcast, ctx);
16271
+ return;
16272
+ }
16273
+ await ctx.queues.advance.add(
16274
+ "advance",
16275
+ { broadcastId: String(broadcast._id) },
16276
+ {
16277
+ attempts: 3,
16278
+ backoff: { type: "exponential", delay: 6e4 },
16279
+ jobId: `broadcast-dispatch:${broadcast._id}`
16053
16280
  }
16281
+ );
16282
+ }
16283
+ async function dispatchBroadcastById(broadcastId, ctx) {
16284
+ const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
16285
+ if (!broadcast) return;
16286
+ await runBroadcastDispatch(broadcast, ctx);
16287
+ }
16288
+ async function resumeStalledBroadcasts(ctx) {
16289
+ const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
16290
+ const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
16291
+ for (const b of stalled) {
16292
+ await ctx.collections.broadcasts.updateOne(
16293
+ { _id: b._id },
16294
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16295
+ );
16296
+ await startBroadcastDispatch(b, ctx);
16297
+ }
16298
+ }
16299
+ async function runBroadcastDispatch(broadcast, ctx) {
16300
+ try {
16301
+ await dispatchBroadcast(broadcast, ctx);
16302
+ } catch (err) {
16303
+ console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
16304
+ await ctx.collections.broadcasts.updateOne(
16305
+ { _id: broadcast._id },
16306
+ { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
16307
+ );
16054
16308
  }
16055
16309
  }
16056
16310
  async function dispatchBroadcast(broadcast, ctx) {
@@ -16071,11 +16325,19 @@ async function dispatchBroadcast(broadcast, ctx) {
16071
16325
  const respectTimezone = broadcast.respectRecipientTimezone === true;
16072
16326
  const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
16073
16327
  for (; ; ) {
16328
+ await ctx.collections.broadcasts.updateOne(
16329
+ { _id: broadcast._id },
16330
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16331
+ );
16074
16332
  const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
16075
16333
  if (page.contacts.length === 0) break;
16076
16334
  const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
16077
16335
  if (eligible.length > 0) {
16078
16336
  while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
16337
+ await ctx.collections.broadcasts.updateOne(
16338
+ { _id: broadcast._id },
16339
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16340
+ );
16079
16341
  await sleep(2e3);
16080
16342
  }
16081
16343
  const sendDocs = await Promise.all(
@@ -16198,7 +16460,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
16198
16460
  const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
16199
16461
  let delayMs = Math.max(0, scheduledMs - Date.now());
16200
16462
  if (respectTimezone && contact.timezone) {
16201
- delayMs = Math.max(0, perRecipientDelayMs(scheduledMs, contact.timezone));
16463
+ const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
16464
+ delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
16202
16465
  }
16203
16466
  const doc = {
16204
16467
  _id: sendId,
@@ -16237,7 +16500,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
16237
16500
  };
16238
16501
  return { doc, delayMs };
16239
16502
  }
16240
- function perRecipientDelayMs(scheduledMs, timezone) {
16503
+ function perRecipientOffsetMs(scheduledMs, timezone) {
16504
+ const DAY_MS = 24 * 60 * 60 * 1e3;
16241
16505
  try {
16242
16506
  const scheduled = new Date(scheduledMs);
16243
16507
  const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
@@ -16250,7 +16514,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
16250
16514
  const utcMs = parse(utc);
16251
16515
  const localMs = parse(local);
16252
16516
  const offsetMs = utcMs - localMs;
16253
- return offsetMs;
16517
+ return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
16254
16518
  } catch {
16255
16519
  return 0;
16256
16520
  }
@@ -16925,6 +17189,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
16925
17189
 
16926
17190
  // src/server/runner/tick.ts
16927
17191
  var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
17192
+ var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
16928
17193
  async function runTick(ctx) {
16929
17194
  try {
16930
17195
  await ctx.collections.health.updateOne(
@@ -16965,12 +17230,18 @@ async function runTick(ctx) {
16965
17230
  await processScheduledBroadcasts2(ctx).catch((err) => {
16966
17231
  console.error("mailery: broadcast dispatch failed", err);
16967
17232
  });
17233
+ await resumeStalledBroadcasts(ctx).catch((err) => {
17234
+ console.error("mailery: stalled-broadcast resume failed", err);
17235
+ });
16968
17236
  await evaluateHealth(ctx).catch((err) => {
16969
17237
  console.error("mailery: health evaluation failed", err);
16970
17238
  });
16971
17239
  await promoteSoftBounces(ctx).catch((err) => {
16972
17240
  console.error("mailery: soft-bounce promotion failed", err);
16973
17241
  });
17242
+ await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
17243
+ console.error("mailery: stranded-webhook drain failed", err);
17244
+ });
16974
17245
  await Promise.all([
16975
17246
  runDnsblChecks(ctx).catch((err) => {
16976
17247
  console.error("mailery: dnsbl checks failed", err);
@@ -17041,140 +17312,6 @@ async function processScheduledBroadcasts2(ctx) {
17041
17312
  await processScheduledBroadcasts(ctx);
17042
17313
  }
17043
17314
 
17044
- // src/server/runner/webhook.ts
17045
- function dimsFromSend(send) {
17046
- if (!send) return null;
17047
- return { fromEmail: send.fromEmail, kind: send.kind };
17048
- }
17049
- async function applyWebhookEvent(event, ctx) {
17050
- const send = await ctx.collections.sends.findOne(
17051
- event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
17052
- { sort: { queuedAt: -1 } }
17053
- );
17054
- switch (event.type) {
17055
- case "delivered":
17056
- if (send) {
17057
- await ctx.collections.sends.updateOne(
17058
- { _id: send._id },
17059
- { $set: { status: "delivered", deliveredAt: event.occurredAt } }
17060
- );
17061
- }
17062
- await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
17063
- break;
17064
- case "open":
17065
- if (send) {
17066
- await ctx.collections.sends.updateOne(
17067
- { _id: send._id },
17068
- {
17069
- $set: {
17070
- openedAt: send.openedAt ?? event.occurredAt,
17071
- status: send.status === "sent" ? "delivered" : send.status
17072
- },
17073
- $inc: { openCount: 1 }
17074
- }
17075
- );
17076
- }
17077
- break;
17078
- case "click":
17079
- if (send) {
17080
- await ctx.collections.sends.updateOne(
17081
- { _id: send._id },
17082
- {
17083
- $set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
17084
- $inc: { clickCount: 1 },
17085
- $push: {
17086
- clickedLinks: {
17087
- url: event.details.clickedUrl ?? "",
17088
- linkId: "",
17089
- clickedAt: event.occurredAt
17090
- }
17091
- }
17092
- }
17093
- );
17094
- }
17095
- break;
17096
- case "bounce": {
17097
- const bounceType = event.details.bounceType ?? "hard";
17098
- if (send) {
17099
- await ctx.collections.sends.updateOne(
17100
- { _id: send._id },
17101
- {
17102
- $set: {
17103
- status: "bounced",
17104
- bounceType,
17105
- bounceReason: event.details.bounceReason ?? null
17106
- }
17107
- }
17108
- );
17109
- }
17110
- if (bounceType === "hard") {
17111
- await suppressOnce(ctx, event.email, "hard_bounce", "all");
17112
- await ctx.collections.subscriptions.updateOne(
17113
- { emailAtSubscribe: event.email },
17114
- { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
17115
- );
17116
- }
17117
- {
17118
- const dims = dimsFromSend(send);
17119
- await recordHealthCounter(ctx, "bounced", dims);
17120
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
17121
- }
17122
- break;
17123
- }
17124
- case "complaint":
17125
- case "spam_report":
17126
- if (send) {
17127
- await ctx.collections.sends.updateOne(
17128
- { _id: send._id },
17129
- { $set: { complainedAt: event.occurredAt, status: "complained" } }
17130
- );
17131
- }
17132
- await suppressOnce(ctx, event.email, "complaint", "all");
17133
- await ctx.collections.subscriptions.updateOne(
17134
- { emailAtSubscribe: event.email },
17135
- { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
17136
- );
17137
- await recordHealthCounter(ctx, "complained", dimsFromSend(send));
17138
- break;
17139
- case "unsubscribe":
17140
- if (send) {
17141
- await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
17142
- }
17143
- await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
17144
- await ctx.collections.subscriptions.updateOne(
17145
- { emailAtSubscribe: event.email },
17146
- {
17147
- $set: {
17148
- status: "unsubscribed",
17149
- unsubscribedAt: event.occurredAt,
17150
- unsubscribeReason: "user_request",
17151
- updatedAt: /* @__PURE__ */ new Date()
17152
- }
17153
- }
17154
- );
17155
- break;
17156
- }
17157
- }
17158
- async function suppressOnce(ctx, email, reason, scope) {
17159
- const normalized = email.toLowerCase();
17160
- await ctx.collections.suppressions.updateOne(
17161
- { email: normalized, scope },
17162
- {
17163
- $setOnInsert: {
17164
- email: normalized,
17165
- emailHash: sha256Hex(normalized),
17166
- scope,
17167
- reason,
17168
- source: "provider_webhook",
17169
- notes: null,
17170
- addedAt: /* @__PURE__ */ new Date(),
17171
- expiresAt: null
17172
- }
17173
- },
17174
- { upsert: true }
17175
- );
17176
- }
17177
-
17178
17315
  // src/server/mailer.ts
17179
17316
  var Mailer = class _Mailer {
17180
17317
  db;
@@ -17639,41 +17776,48 @@ var Mailer = class _Mailer {
17639
17776
  if (existing) return { sendId: String(existing._id) };
17640
17777
  const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
17641
17778
  const sendId = new ObjectId();
17642
- await this.collections.sends.insertOne({
17643
- _id: sendId,
17644
- dedupeKey,
17645
- externalId: parsed.externalId,
17646
- emailAtSend: contact.email,
17647
- templateId: template._id,
17648
- templateSlug: template.slug,
17649
- flowRunId: null,
17650
- broadcastId: null,
17651
- manualSendBy: "sendOneOff",
17652
- kind: template.kind,
17653
- provider: providerName,
17654
- providerMessageId: null,
17655
- fromName: template.fromName,
17656
- fromEmail: template.fromEmail,
17657
- subject: template.subject,
17658
- bodyHash: "",
17659
- status: "queued",
17660
- errorMessage: null,
17661
- bounceType: null,
17662
- bounceReason: null,
17663
- links: [],
17664
- vars: parsed.vars ?? {},
17665
- openedAt: null,
17666
- openCount: 0,
17667
- firstClickAt: null,
17668
- clickCount: 0,
17669
- clickedLinks: [],
17670
- unsubscribedAt: null,
17671
- complainedAt: null,
17672
- queuedAt: /* @__PURE__ */ new Date(),
17673
- updatedAt: /* @__PURE__ */ new Date(),
17674
- sentAt: null,
17675
- deliveredAt: null
17676
- });
17779
+ try {
17780
+ await this.collections.sends.insertOne({
17781
+ _id: sendId,
17782
+ dedupeKey,
17783
+ externalId: parsed.externalId,
17784
+ emailAtSend: contact.email,
17785
+ templateId: template._id,
17786
+ templateSlug: template.slug,
17787
+ flowRunId: null,
17788
+ broadcastId: null,
17789
+ manualSendBy: "sendOneOff",
17790
+ kind: template.kind,
17791
+ provider: providerName,
17792
+ providerMessageId: null,
17793
+ fromName: template.fromName,
17794
+ fromEmail: template.fromEmail,
17795
+ subject: template.subject,
17796
+ bodyHash: "",
17797
+ status: "queued",
17798
+ errorMessage: null,
17799
+ bounceType: null,
17800
+ bounceReason: null,
17801
+ links: [],
17802
+ vars: parsed.vars ?? {},
17803
+ openedAt: null,
17804
+ openCount: 0,
17805
+ firstClickAt: null,
17806
+ clickCount: 0,
17807
+ clickedLinks: [],
17808
+ unsubscribedAt: null,
17809
+ complainedAt: null,
17810
+ queuedAt: /* @__PURE__ */ new Date(),
17811
+ updatedAt: /* @__PURE__ */ new Date(),
17812
+ sentAt: null,
17813
+ deliveredAt: null
17814
+ });
17815
+ } catch (err) {
17816
+ if (err?.code !== 11e3) throw err;
17817
+ const winner = await this.collections.sends.findOne({ dedupeKey });
17818
+ if (winner) return { sendId: String(winner._id) };
17819
+ throw err;
17820
+ }
17677
17821
  await this.queues.send.add(
17678
17822
  "send",
17679
17823
  { sendId: String(sendId) },
@@ -17717,7 +17861,12 @@ var Mailer = class _Mailer {
17717
17861
  await runTick(this.runnerContext);
17718
17862
  },
17719
17863
  advance: async (data) => {
17720
- if (!ObjectId.isValid(data.flowRunId)) return;
17864
+ if (data.broadcastId) {
17865
+ if (!ObjectId.isValid(data.broadcastId)) return;
17866
+ await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
17867
+ return;
17868
+ }
17869
+ if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
17721
17870
  await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
17722
17871
  },
17723
17872
  send: async (data) => {
@@ -17733,27 +17882,7 @@ var Mailer = class _Mailer {
17733
17882
  }
17734
17883
  /** Process unprocessed webhook events in mailer_webhook_events. */
17735
17884
  async processWebhookBacklog() {
17736
- const batch = await this.collections.webhookEvents.find({ processed: false }).limit(500).toArray();
17737
- for (const evt of batch) {
17738
- try {
17739
- const normalized = evt.raw?.normalized;
17740
- const details = normalized?.details ?? {};
17741
- await applyWebhookEvent(
17742
- {
17743
- type: evt.normalizedType,
17744
- providerEventId: evt.providerEventId,
17745
- providerMessageId: evt.providerMessageId,
17746
- email: evt.email,
17747
- occurredAt: evt.occurredAt,
17748
- details
17749
- },
17750
- this.runnerContext
17751
- );
17752
- await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
17753
- } catch (err) {
17754
- console.error("mailery: webhook apply failed", { id: String(evt._id), err });
17755
- }
17756
- }
17885
+ await processWebhookBacklog(this.runnerContext);
17757
17886
  }
17758
17887
  async stop() {
17759
17888
  await this.queueDriver.close();