mailery 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -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 },
@@ -14617,7 +14623,7 @@ var BullDriver = class _BullDriver {
14617
14623
  bullQueues;
14618
14624
  workers = null;
14619
14625
  bull;
14620
- static async create(redisConfig) {
14626
+ static async create(redisConfig, prefix) {
14621
14627
  let bull;
14622
14628
  try {
14623
14629
  bull = await import('bullmq');
@@ -14626,13 +14632,27 @@ var BullDriver = class _BullDriver {
14626
14632
  "mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
14627
14633
  );
14628
14634
  }
14635
+ if (prefix?.includes(":")) {
14636
+ throw new Error(
14637
+ `mailery: queue prefix "${prefix}" must not contain ':' \u2014 BullMQ uses it as the Redis key separator.`
14638
+ );
14639
+ }
14629
14640
  const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
14630
- return new _BullDriver(bull, redis);
14641
+ return new _BullDriver(bull, redis, prefix);
14631
14642
  }
14632
- constructor(bull, redis) {
14643
+ prefix;
14644
+ constructor(bull, redis, prefix) {
14633
14645
  this.bull = bull;
14634
14646
  this.redis = redis;
14635
- const opts = { connection: redis };
14647
+ this.prefix = prefix;
14648
+ const opts = {
14649
+ connection: redis,
14650
+ prefix,
14651
+ defaultJobOptions: {
14652
+ removeOnComplete: { age: 24 * 3600, count: 1e3 },
14653
+ removeOnFail: { age: 7 * 24 * 3600 }
14654
+ }
14655
+ };
14636
14656
  this.bullQueues = {
14637
14657
  tick: new bull.Queue(QUEUE_NAMES.tick, opts),
14638
14658
  advance: new bull.Queue(QUEUE_NAMES.advance, opts),
@@ -14650,12 +14670,16 @@ var BullDriver = class _BullDriver {
14650
14670
  await this.bullQueues.tick.upsertJobScheduler(
14651
14671
  "mailer-tick-repeat",
14652
14672
  { every: intervalSeconds * 1e3 },
14653
- { name: "tick", data: {} }
14673
+ {
14674
+ name: "tick",
14675
+ data: {},
14676
+ opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
14677
+ }
14654
14678
  );
14655
14679
  }
14656
14680
  async startWorkers(opts) {
14657
14681
  if (this.workers) return;
14658
- const base = { connection: this.redis };
14682
+ const base = { connection: this.redis, prefix: this.prefix };
14659
14683
  const { Worker } = this.bull;
14660
14684
  const tick = new Worker(
14661
14685
  QUEUE_NAMES.tick,
@@ -14760,9 +14784,10 @@ var AgendaDriver = class _AgendaDriver {
14760
14784
  "mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
14761
14785
  );
14762
14786
  }
14787
+ const collectionName = opts.collectionName ?? "_mailerJobs";
14763
14788
  const backend = new backendMod.MongoBackend({
14764
14789
  mongo: opts.db,
14765
- collection: opts.collectionName ?? "_mailerJobs"
14790
+ collection: collectionName
14766
14791
  });
14767
14792
  const agenda = new agendaMod.Agenda({
14768
14793
  backend,
@@ -14771,13 +14796,15 @@ var AgendaDriver = class _AgendaDriver {
14771
14796
  maxConcurrency: 50,
14772
14797
  defaultConcurrency: 5
14773
14798
  });
14774
- return new _AgendaDriver(agenda, agendaMod, opts.db);
14799
+ return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
14775
14800
  }
14776
14801
  db;
14777
- constructor(agenda, agendaMod, db) {
14802
+ collName;
14803
+ constructor(agenda, agendaMod, db, collectionName) {
14778
14804
  this.agenda = agenda;
14779
14805
  this.agendaMod = agendaMod;
14780
14806
  this.db = db;
14807
+ this.collName = collectionName;
14781
14808
  this.queues = {
14782
14809
  tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
14783
14810
  advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
@@ -14810,10 +14837,7 @@ var AgendaDriver = class _AgendaDriver {
14810
14837
  }
14811
14838
  /** Direct access to the Mongo collection Agenda persists jobs into. */
14812
14839
  jobsCollection() {
14813
- return this.db.collection(this.collectionName());
14814
- }
14815
- collectionName() {
14816
- return "_mailerJobs";
14840
+ return this.db.collection(this.collName);
14817
14841
  }
14818
14842
  async findPending(name, jobId) {
14819
14843
  return this.jobsCollection().findOne({
@@ -14823,12 +14847,6 @@ var AgendaDriver = class _AgendaDriver {
14823
14847
  });
14824
14848
  }
14825
14849
  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
14850
  await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
14833
14851
  }
14834
14852
  async startWorkers(opts) {
@@ -14913,7 +14931,7 @@ var NoopDriver = class {
14913
14931
  async function createQueueDriver(config, fallbackDb) {
14914
14932
  switch (config.driver) {
14915
14933
  case "bull":
14916
- return BullDriver.create(config.redis);
14934
+ return BullDriver.create(config.redis, config.prefix);
14917
14935
  case "agenda":
14918
14936
  return AgendaDriver.create({
14919
14937
  db: config.db ?? fallbackDb,
@@ -14931,6 +14949,7 @@ async function createQueueDriver(config, fallbackDb) {
14931
14949
 
14932
14950
  // src/server/runner/triggers.ts
14933
14951
  var BATCH_SIZE = 1e3;
14952
+ var SCAN_OVERLAP_MS = 3e4;
14934
14953
  async function processNewlyFiredEventTriggers(ctx) {
14935
14954
  const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
14936
14955
  for (const flow of flows) {
@@ -14941,16 +14960,19 @@ async function processFlowTriggers(flow, ctx) {
14941
14960
  const eventName = flow.trigger.eventName;
14942
14961
  if (!eventName) return;
14943
14962
  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();
14963
+ const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
14964
+ const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
14945
14965
  if (events.length === 0) return;
14946
14966
  for (const event of events) {
14947
14967
  await tryEnterFlow(flow, event, ctx);
14948
14968
  }
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
- );
14969
+ const newestCreatedAt = events[events.length - 1].createdAt;
14970
+ if (newestCreatedAt.getTime() > since.getTime()) {
14971
+ await ctx.collections.flows.updateOne(
14972
+ { _id: flow._id },
14973
+ { $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
14974
+ );
14975
+ }
14954
14976
  }
14955
14977
  async function tryEnterFlow(flow, event, ctx) {
14956
14978
  if (flow.trigger.once) {
@@ -14962,117 +14984,453 @@ async function tryEnterFlow(flow, event, ctx) {
14962
14984
  }
14963
14985
  const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
14964
14986
  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
- });
14987
+ let result;
14988
+ try {
14989
+ result = await ctx.collections.flowRuns.insertOne({
14990
+ externalId: event.externalId,
14991
+ flowId: flow._id,
14992
+ flowSlug: flow.slug,
14993
+ flowVersion: flow.version,
14994
+ emailAtEntry: sub.emailAtSubscribe,
14995
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14996
+ triggerDedupeKey: event.dedupeKey,
14997
+ enteredAt: /* @__PURE__ */ new Date(),
14998
+ status: "active",
14999
+ currentStepIndex: 0,
15000
+ currentBranchPath: [],
15001
+ nextActionAt: /* @__PURE__ */ new Date(),
15002
+ attemptsForCurrentStep: 0,
15003
+ history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
15004
+ exitedAt: null,
15005
+ exitReason: null,
15006
+ createdAt: /* @__PURE__ */ new Date(),
15007
+ updatedAt: /* @__PURE__ */ new Date()
15008
+ });
15009
+ } catch (err) {
15010
+ if (err?.code !== 11e3) throw err;
15011
+ return;
15012
+ }
14984
15013
  await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
14985
15014
  }
14986
15015
 
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;
15016
+ // src/server/templates/sender-domain.ts
15017
+ function extractDomain(email) {
15018
+ if (typeof email !== "string") return null;
15019
+ const at = email.lastIndexOf("@");
15020
+ if (at <= 0 || at === email.length - 1) return null;
15021
+ return email.slice(at + 1).toLowerCase().trim();
15022
+ }
15023
+
15024
+ // src/server/runner/health.ts
15025
+ var ZERO_COUNTERS = {
15026
+ sent: 0,
15027
+ delivered: 0,
15028
+ bounced: 0,
15029
+ hardBounced: 0,
15030
+ softBounced: 0,
15031
+ complained: 0,
15032
+ failedToSend: 0
15033
+ };
15034
+ var ZERO_RATES = {
15035
+ bounceRate: 0,
15036
+ hardBounceRate: 0,
15037
+ complaintRate: 0,
15038
+ failureRate: 0
15039
+ };
15040
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15041
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
15042
+ const writes = [];
15043
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
15044
+ if (dims) {
15045
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
15046
+ if (domain) {
15047
+ const id = healthBucketId(domain, dims.kind);
15048
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
15024
15049
  }
15025
- return false;
15026
15050
  }
15027
- if ("not" in p) {
15028
- return !await evaluatePredicate(p.not, ctx);
15029
- }
15030
- return false;
15051
+ await Promise.all(writes);
15031
15052
  }
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;
15053
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
15054
+ await ctx.collections.health.updateOne(
15055
+ { _id },
15056
+ {
15057
+ $inc: { [`counters.${counter2}`]: by },
15058
+ $setOnInsert: {
15059
+ _id,
15060
+ senderDomain,
15061
+ kind,
15062
+ windowStartedAt: /* @__PURE__ */ new Date(),
15063
+ windowDurationMs: windowMs,
15064
+ status: "healthy",
15065
+ trippedAt: null,
15066
+ trippedReason: null,
15067
+ manuallyResumedAt: null,
15068
+ rates: { ...ZERO_RATES }
15069
+ },
15070
+ $set: { updatedAt: /* @__PURE__ */ new Date() }
15071
+ },
15072
+ { upsert: true }
15073
+ );
15038
15074
  }
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);
15075
+ async function evaluateHealth(ctx) {
15076
+ const cb = ctx.config.circuitBreaker;
15077
+ const windowMs = cb.windowMinutes * 60 * 1e3;
15078
+ const docs = await ctx.collections.health.find({}).toArray();
15079
+ if (docs.length === 0) return;
15080
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
15081
+ if (!hasAgg) {
15082
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
15048
15083
  }
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++;
15084
+ for (const doc of docs) {
15085
+ const isAgg = doc._id === HEALTH_AGG_ID;
15086
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15087
+ if (windowAge > windowMs && doc.status !== "tripped") {
15088
+ await ctx.collections.health.updateOne(
15089
+ { _id: doc._id },
15090
+ {
15091
+ $set: {
15092
+ windowStartedAt: /* @__PURE__ */ new Date(),
15093
+ windowDurationMs: windowMs,
15094
+ counters: { ...ZERO_COUNTERS },
15095
+ rates: { ...ZERO_RATES },
15096
+ status: "healthy",
15097
+ updatedAt: /* @__PURE__ */ new Date()
15098
+ }
15099
+ }
15100
+ );
15054
15101
  continue;
15055
15102
  }
15056
- const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
15057
- if (hasHumanClick) n++;
15103
+ const c = doc.counters;
15104
+ const total = c.sent || 1;
15105
+ const rates = {
15106
+ bounceRate: c.bounced / total,
15107
+ hardBounceRate: c.hardBounced / total,
15108
+ complaintRate: c.complained / total,
15109
+ failureRate: c.failedToSend / total
15110
+ };
15111
+ await ctx.collections.health.updateOne(
15112
+ { _id: doc._id },
15113
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15114
+ );
15115
+ if (isAgg) continue;
15116
+ if (c.sent < cb.minSendsBeforeEval) continue;
15117
+ if (doc.status === "tripped") continue;
15118
+ let trippedReason = null;
15119
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15120
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15121
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15122
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15123
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15124
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15125
+ }
15126
+ if (trippedReason) {
15127
+ const result = await ctx.collections.health.updateOne(
15128
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
15129
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15130
+ );
15131
+ if (result.modifiedCount > 0) {
15132
+ if (ctx.audit) {
15133
+ try {
15134
+ await ctx.audit({
15135
+ actor: "system:circuit-breaker",
15136
+ action: "health.trip",
15137
+ resource: {
15138
+ collection: "mailer_health",
15139
+ id: String(doc._id),
15140
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
15141
+ },
15142
+ diffSummary: trippedReason
15143
+ });
15144
+ } catch {
15145
+ }
15146
+ }
15147
+ if (ctx.config.onCircuitBreakerTrip) {
15148
+ try {
15149
+ await ctx.config.onCircuitBreakerTrip({
15150
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
15151
+ rates
15152
+ });
15153
+ } catch {
15154
+ }
15155
+ }
15156
+ }
15157
+ continue;
15158
+ }
15159
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15160
+ if (doc.status !== "degraded") {
15161
+ await ctx.collections.health.updateOne(
15162
+ { _id: doc._id },
15163
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15164
+ );
15165
+ }
15166
+ } else if (doc.status === "degraded") {
15167
+ await ctx.collections.health.updateOne(
15168
+ { _id: doc._id },
15169
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15170
+ );
15171
+ }
15058
15172
  }
15059
- return n;
15060
15173
  }
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;
15174
+ async function getBucketStatus(ctx, fromEmail, kind) {
15175
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
15176
+ const id = healthBucketId(domain, kind);
15177
+ return ctx.collections.health.findOne({ _id: id });
15068
15178
  }
15069
15179
 
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) {
15180
+ // src/server/runner/webhook.ts
15181
+ function dimsFromSend(send) {
15182
+ if (!send) return null;
15183
+ return { fromEmail: send.fromEmail, kind: send.kind };
15184
+ }
15185
+ async function processWebhookBacklog(ctx, opts = {}) {
15186
+ const filter = { processed: false };
15187
+ if (opts.olderThanMs) {
15188
+ filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
15189
+ }
15190
+ const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
15191
+ for (const evt of batch) {
15192
+ const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
15193
+ { _id: evt._id, processed: false },
15194
+ { $set: { processed: true } }
15195
+ );
15196
+ if (!claimed) continue;
15197
+ try {
15198
+ const normalized = evt.raw?.normalized;
15199
+ const details = normalized?.details ?? {};
15200
+ await applyWebhookEvent(
15201
+ {
15202
+ type: evt.normalizedType,
15203
+ providerEventId: evt.providerEventId,
15204
+ providerMessageId: evt.providerMessageId,
15205
+ email: evt.email,
15206
+ occurredAt: evt.occurredAt,
15207
+ details
15208
+ },
15209
+ ctx
15210
+ );
15211
+ } catch (err) {
15212
+ console.error("mailery: webhook apply failed", { id: String(evt._id), err });
15213
+ }
15214
+ }
15215
+ }
15216
+ async function applyWebhookEvent(event, ctx) {
15217
+ const send = await ctx.collections.sends.findOne(
15218
+ event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
15219
+ { sort: { queuedAt: -1 } }
15220
+ );
15221
+ switch (event.type) {
15222
+ case "delivered":
15223
+ if (send) {
15224
+ await ctx.collections.sends.updateOne(
15225
+ { _id: send._id },
15226
+ { $set: { status: "delivered", deliveredAt: event.occurredAt } }
15227
+ );
15228
+ }
15229
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
15230
+ break;
15231
+ case "open":
15232
+ if (send) {
15233
+ await ctx.collections.sends.updateOne(
15234
+ { _id: send._id },
15235
+ {
15236
+ $set: {
15237
+ openedAt: send.openedAt ?? event.occurredAt,
15238
+ status: send.status === "sent" ? "delivered" : send.status
15239
+ },
15240
+ $inc: { openCount: 1 }
15241
+ }
15242
+ );
15243
+ }
15244
+ break;
15245
+ case "click":
15246
+ if (send) {
15247
+ await ctx.collections.sends.updateOne(
15248
+ { _id: send._id },
15249
+ {
15250
+ $set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
15251
+ $inc: { clickCount: 1 },
15252
+ $push: {
15253
+ clickedLinks: {
15254
+ url: event.details.clickedUrl ?? "",
15255
+ linkId: "",
15256
+ clickedAt: event.occurredAt
15257
+ }
15258
+ }
15259
+ }
15260
+ );
15261
+ }
15262
+ break;
15263
+ case "bounce": {
15264
+ const bounceType = event.details.bounceType ?? "hard";
15265
+ if (send) {
15266
+ await ctx.collections.sends.updateOne(
15267
+ { _id: send._id },
15268
+ {
15269
+ $set: {
15270
+ status: "bounced",
15271
+ bounceType,
15272
+ bounceReason: event.details.bounceReason ?? null
15273
+ }
15274
+ }
15275
+ );
15276
+ }
15277
+ if (bounceType === "hard") {
15278
+ await suppressOnce(ctx, event.email, "hard_bounce", "all");
15279
+ await ctx.collections.subscriptions.updateOne(
15280
+ { emailAtSubscribe: event.email },
15281
+ { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
15282
+ );
15283
+ }
15284
+ {
15285
+ const dims = dimsFromSend(send);
15286
+ await recordHealthCounter(ctx, "bounced", dims);
15287
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
15288
+ }
15289
+ break;
15290
+ }
15291
+ case "complaint":
15292
+ case "spam_report":
15293
+ if (send) {
15294
+ await ctx.collections.sends.updateOne(
15295
+ { _id: send._id },
15296
+ { $set: { complainedAt: event.occurredAt, status: "complained" } }
15297
+ );
15298
+ }
15299
+ await suppressOnce(ctx, event.email, "complaint", "all");
15300
+ await ctx.collections.subscriptions.updateOne(
15301
+ { emailAtSubscribe: event.email },
15302
+ { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
15303
+ );
15304
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
15305
+ break;
15306
+ case "unsubscribe":
15307
+ if (send) {
15308
+ await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
15309
+ }
15310
+ await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
15311
+ await ctx.collections.subscriptions.updateOne(
15312
+ { emailAtSubscribe: event.email },
15313
+ {
15314
+ $set: {
15315
+ status: "unsubscribed",
15316
+ unsubscribedAt: event.occurredAt,
15317
+ unsubscribeReason: "user_request",
15318
+ updatedAt: /* @__PURE__ */ new Date()
15319
+ }
15320
+ }
15321
+ );
15322
+ break;
15323
+ }
15324
+ }
15325
+ async function suppressOnce(ctx, email, reason, scope) {
15326
+ const normalized = email.toLowerCase();
15327
+ await ctx.collections.suppressions.updateOne(
15328
+ { email: normalized, scope },
15329
+ {
15330
+ $setOnInsert: {
15331
+ email: normalized,
15332
+ emailHash: sha256Hex(normalized),
15333
+ scope,
15334
+ reason,
15335
+ source: "provider_webhook",
15336
+ notes: null,
15337
+ addedAt: /* @__PURE__ */ new Date(),
15338
+ expiresAt: null
15339
+ }
15340
+ },
15341
+ { upsert: true }
15342
+ );
15343
+ }
15344
+
15345
+ // src/server/runner/predicate.ts
15346
+ var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
15347
+ async function evaluatePredicate(predicate, ctx) {
15348
+ const p = predicate;
15349
+ if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
15350
+ if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
15351
+ if ("fieldEquals" in p) {
15352
+ return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
15353
+ }
15354
+ if ("fieldExists" in p) {
15355
+ return ctx.contact.fields[p.fieldExists] !== void 0;
15356
+ }
15357
+ if ("subscriptionStatus" in p) {
15358
+ const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
15359
+ return sub?.status === p.subscriptionStatus;
15360
+ }
15361
+ if ("hasFiredEvent" in p) {
15362
+ return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
15363
+ }
15364
+ if ("notHasFiredEvent" in p) {
15365
+ return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
15366
+ }
15367
+ if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
15368
+ if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
15369
+ if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
15370
+ if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
15371
+ if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
15372
+ if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
15373
+ if ("all" in p) {
15374
+ for (const sub of p.all) {
15375
+ if (!await evaluatePredicate(sub, ctx)) return false;
15376
+ }
15377
+ return true;
15378
+ }
15379
+ if ("any" in p) {
15380
+ for (const sub of p.any) {
15381
+ if (await evaluatePredicate(sub, ctx)) return true;
15382
+ }
15383
+ return false;
15384
+ }
15385
+ if ("not" in p) {
15386
+ return !await evaluatePredicate(p.not, ctx);
15387
+ }
15388
+ return false;
15389
+ }
15390
+ async function hasEvent(ctx, name, opts) {
15391
+ const filter = { externalId: ctx.contact.externalId, name };
15392
+ const lower = effectiveLowerBound(ctx, opts);
15393
+ if (lower) filter.occurredAt = { $gt: lower };
15394
+ const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
15395
+ return !!found;
15396
+ }
15397
+ async function openOrClickCount(ctx, kind, opts, excludeBots) {
15398
+ const filter = { externalId: ctx.contact.externalId };
15399
+ if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
15400
+ if (kind === "opened") filter.openedAt = { $ne: null };
15401
+ if (kind === "clicked") filter.firstClickAt = { $ne: null };
15402
+ const lower = effectiveLowerBound(ctx, opts);
15403
+ if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
15404
+ if (!excludeBots) {
15405
+ return await ctx.collections.sends.countDocuments(filter);
15406
+ }
15407
+ const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
15408
+ let n = 0;
15409
+ for (const s of docs) {
15410
+ if (kind === "opened") {
15411
+ n++;
15412
+ continue;
15413
+ }
15414
+ const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
15415
+ if (hasHumanClick) n++;
15416
+ }
15417
+ return n;
15418
+ }
15419
+ function effectiveLowerBound(ctx, opts) {
15420
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
15421
+ if (opts.sinceFlowStart) return ctx.run.enteredAt;
15422
+ if (opts.withinDays && opts.withinDays > 0) {
15423
+ return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
15424
+ }
15425
+ return null;
15426
+ }
15427
+
15428
+ // src/server/runner/delivery-window.ts
15429
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
15430
+ function computeDeliveryTime(now, window2, contactTimezone) {
15431
+ const tz = pickTimezone(window2, contactTimezone);
15432
+ let candidate = now;
15433
+ if (window2.timeOfDay) {
15076
15434
  const [hh, mm] = window2.timeOfDay.split(":").map(Number);
15077
15435
  const local = localParts(candidate, tz);
15078
15436
  const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
@@ -15207,7 +15565,8 @@ function applyTracking(html, opts) {
15207
15565
  const seen = /* @__PURE__ */ new Map();
15208
15566
  let out = html;
15209
15567
  if (opts.trackClicks) {
15210
- out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, url, post) => {
15568
+ out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
15569
+ const url = decodeHtmlEntities(rawUrl);
15211
15570
  if (shouldSkipClickRewrite(url, preserve, full)) return full;
15212
15571
  let linkId = seen.get(url);
15213
15572
  if (!linkId) {
@@ -15239,236 +15598,95 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
15239
15598
  if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
15240
15599
  return false;
15241
15600
  }
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"]
15292
- };
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
15601
+ var NAMED_ENTITIES = {
15602
+ amp: "&",
15603
+ lt: "<",
15604
+ gt: ">",
15605
+ quot: '"',
15606
+ apos: "'",
15607
+ nbsp: "\xA0"
15333
15608
  };
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
- }
15609
+ function decodeHtmlEntities(input) {
15610
+ return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
15611
+ if (body[0] === "#") {
15612
+ const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
15613
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
15614
+ try {
15615
+ return String.fromCodePoint(code);
15616
+ } catch {
15617
+ return match;
15450
15618
  }
15451
- continue;
15452
15619
  }
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
- );
15620
+ const named = NAMED_ENTITIES[body.toLowerCase()];
15621
+ return named ?? match;
15622
+ });
15623
+ }
15624
+ function shortHash(input) {
15625
+ return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
15626
+ }
15627
+ function makeHandlebars(extra) {
15628
+ const hb = Handlebars.create();
15629
+ hb.registerHelper("eq", (a, b) => a === b);
15630
+ hb.registerHelper("ne", (a, b) => a !== b);
15631
+ hb.registerHelper("gt", (a, b) => a > b);
15632
+ hb.registerHelper("lt", (a, b) => a < b);
15633
+ hb.registerHelper("gte", (a, b) => a >= b);
15634
+ hb.registerHelper("lte", (a, b) => a <= b);
15635
+ hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
15636
+ hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
15637
+ hb.registerHelper("not", (a) => !a);
15638
+ hb.registerHelper("formatDate", (value, fmt) => {
15639
+ if (!value) return "";
15640
+ const d = value instanceof Date ? value : new Date(String(value));
15641
+ if (Number.isNaN(d.getTime())) return "";
15642
+ if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
15643
+ if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
15644
+ return d.toISOString().slice(0, 10);
15645
+ });
15646
+ hb.registerHelper("formatNumber", (n) => {
15647
+ const v = Number(n);
15648
+ return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
15649
+ });
15650
+ hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
15651
+ const v = Number(cents);
15652
+ if (!Number.isFinite(v)) return "";
15653
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
15654
+ });
15655
+ hb.registerHelper(
15656
+ "pluralize",
15657
+ (n, one, many) => Number(n) === 1 ? one : many
15658
+ );
15659
+ if (extra) {
15660
+ for (const [name, fn] of Object.entries(extra)) {
15661
+ hb.registerHelper(name, fn);
15465
15662
  }
15466
15663
  }
15664
+ return hb;
15467
15665
  }
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 });
15666
+
15667
+ // src/server/runner/send.ts
15668
+ init_vars();
15669
+
15670
+ // src/server/runner/suppression.ts
15671
+ var SCOPES_BY_KIND = {
15672
+ marketing: ["all", "marketing"],
15673
+ transactional: ["all", "transactional"]
15674
+ };
15675
+ async function isSuppressed(collections, email, kind) {
15676
+ const normalized = email.toLowerCase();
15677
+ const allowed = SCOPES_BY_KIND[kind];
15678
+ const byEmail = await collections.suppressions.findOne({
15679
+ email: normalized,
15680
+ scope: { $in: allowed },
15681
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
15682
+ });
15683
+ if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
15684
+ const hashed = await collections.suppressions.findOne({
15685
+ emailHash: sha256Hex(normalized),
15686
+ scope: { $in: allowed }
15687
+ });
15688
+ if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
15689
+ return { suppressed: false };
15472
15690
  }
15473
15691
 
15474
15692
  // src/server/runner/send.ts
@@ -15536,9 +15754,11 @@ async function handleSend(run, step2, contact, flow, ctx) {
15536
15754
  await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
15537
15755
  }
15538
15756
  async function dispatchSend(sendId, ctx) {
15539
- const send = await ctx.collections.sends.findOne({ _id: sendId });
15757
+ const send = await ctx.collections.sends.findOneAndUpdate(
15758
+ { _id: sendId, status: { $in: ["queued", "failed"] } },
15759
+ { $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
15760
+ );
15540
15761
  if (!send) return;
15541
- if (send.status !== "queued" && send.status !== "failed") return;
15542
15762
  const template = await ctx.collections.templates.findOne({ _id: send.templateId });
15543
15763
  if (!template) {
15544
15764
  await markFailed(send._id, "template_missing", ctx);
@@ -15555,6 +15775,10 @@ async function dispatchSend(sendId, ctx) {
15555
15775
  if (send.kind === "marketing") {
15556
15776
  const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
15557
15777
  if (bucket?.status === "tripped") {
15778
+ await ctx.collections.sends.updateOne(
15779
+ { _id: send._id },
15780
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
15781
+ );
15558
15782
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
15559
15783
  return;
15560
15784
  }
@@ -15803,7 +16027,7 @@ async function handleWait(run, step2, ctx) {
15803
16027
  await ctx.queues.advance.add(
15804
16028
  "advance",
15805
16029
  { flowRunId: String(run._id) },
15806
- { delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
16030
+ { delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
15807
16031
  );
15808
16032
  }
15809
16033
  async function handleCondition(run, step2, contact, ctx) {
@@ -15915,7 +16139,7 @@ async function handleWebhookStep(run, step2, ctx) {
15915
16139
  await ctx.queues.advance.add(
15916
16140
  "advance",
15917
16141
  { flowRunId: String(run._id) },
15918
- { delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
16142
+ { delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
15919
16143
  );
15920
16144
  }
15921
16145
  }
@@ -15944,7 +16168,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
15944
16168
  { flowRunId: String(run._id) },
15945
16169
  {
15946
16170
  delay: Math.max(0, deliverAt.getTime() - Date.now()),
15947
- jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
16171
+ jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
15948
16172
  }
15949
16173
  );
15950
16174
  }
@@ -15999,13 +16223,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
15999
16223
  let arr = steps;
16000
16224
  for (let i = 0; i < branchPath.length; i += 3) {
16001
16225
  const parentIndex = branchPath[i];
16002
- const branchKey = branchPath[i + 1];
16226
+ const branchKey2 = branchPath[i + 1];
16003
16227
  const parent = arr[parentIndex];
16004
16228
  if (!parent || parent.type !== "branch") return null;
16005
- arr = branchKey === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
16229
+ arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
16006
16230
  }
16007
16231
  return arr[currentStepIndex] ?? null;
16008
16232
  }
16233
+ function branchKey(run) {
16234
+ return run.currentBranchPath.join("_");
16235
+ }
16009
16236
  function unitToMs(value, unit) {
16010
16237
  const m = 6e4;
16011
16238
  switch (unit) {
@@ -16032,6 +16259,7 @@ async function sweepStrandedFlowRuns(ctx) {
16032
16259
  }
16033
16260
  }
16034
16261
  }
16262
+ var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
16035
16263
  async function processScheduledBroadcasts(ctx) {
16036
16264
  const now = /* @__PURE__ */ new Date();
16037
16265
  const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
@@ -16042,15 +16270,49 @@ async function processScheduledBroadcasts(ctx) {
16042
16270
  { returnDocument: "after" }
16043
16271
  );
16044
16272
  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
- );
16273
+ await startBroadcastDispatch(claimed, ctx);
16274
+ }
16275
+ }
16276
+ async function startBroadcastDispatch(broadcast, ctx) {
16277
+ if (ctx.config.queue.driver === "noop") {
16278
+ await runBroadcastDispatch(broadcast, ctx);
16279
+ return;
16280
+ }
16281
+ await ctx.queues.advance.add(
16282
+ "advance",
16283
+ { broadcastId: String(broadcast._id) },
16284
+ {
16285
+ attempts: 3,
16286
+ backoff: { type: "exponential", delay: 6e4 },
16287
+ jobId: `broadcast-dispatch:${broadcast._id}`
16053
16288
  }
16289
+ );
16290
+ }
16291
+ async function dispatchBroadcastById(broadcastId, ctx) {
16292
+ const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
16293
+ if (!broadcast) return;
16294
+ await runBroadcastDispatch(broadcast, ctx);
16295
+ }
16296
+ async function resumeStalledBroadcasts(ctx) {
16297
+ const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
16298
+ const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
16299
+ for (const b of stalled) {
16300
+ await ctx.collections.broadcasts.updateOne(
16301
+ { _id: b._id },
16302
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16303
+ );
16304
+ await startBroadcastDispatch(b, ctx);
16305
+ }
16306
+ }
16307
+ async function runBroadcastDispatch(broadcast, ctx) {
16308
+ try {
16309
+ await dispatchBroadcast(broadcast, ctx);
16310
+ } catch (err) {
16311
+ console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
16312
+ await ctx.collections.broadcasts.updateOne(
16313
+ { _id: broadcast._id },
16314
+ { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
16315
+ );
16054
16316
  }
16055
16317
  }
16056
16318
  async function dispatchBroadcast(broadcast, ctx) {
@@ -16071,11 +16333,19 @@ async function dispatchBroadcast(broadcast, ctx) {
16071
16333
  const respectTimezone = broadcast.respectRecipientTimezone === true;
16072
16334
  const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
16073
16335
  for (; ; ) {
16336
+ await ctx.collections.broadcasts.updateOne(
16337
+ { _id: broadcast._id },
16338
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16339
+ );
16074
16340
  const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
16075
16341
  if (page.contacts.length === 0) break;
16076
16342
  const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
16077
16343
  if (eligible.length > 0) {
16078
16344
  while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
16345
+ await ctx.collections.broadcasts.updateOne(
16346
+ { _id: broadcast._id },
16347
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16348
+ );
16079
16349
  await sleep(2e3);
16080
16350
  }
16081
16351
  const sendDocs = await Promise.all(
@@ -16198,7 +16468,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
16198
16468
  const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
16199
16469
  let delayMs = Math.max(0, scheduledMs - Date.now());
16200
16470
  if (respectTimezone && contact.timezone) {
16201
- delayMs = Math.max(0, perRecipientDelayMs(scheduledMs, contact.timezone));
16471
+ const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
16472
+ delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
16202
16473
  }
16203
16474
  const doc = {
16204
16475
  _id: sendId,
@@ -16237,7 +16508,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
16237
16508
  };
16238
16509
  return { doc, delayMs };
16239
16510
  }
16240
- function perRecipientDelayMs(scheduledMs, timezone) {
16511
+ function perRecipientOffsetMs(scheduledMs, timezone) {
16512
+ const DAY_MS = 24 * 60 * 60 * 1e3;
16241
16513
  try {
16242
16514
  const scheduled = new Date(scheduledMs);
16243
16515
  const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
@@ -16250,7 +16522,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
16250
16522
  const utcMs = parse(utc);
16251
16523
  const localMs = parse(local);
16252
16524
  const offsetMs = utcMs - localMs;
16253
- return offsetMs;
16525
+ return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
16254
16526
  } catch {
16255
16527
  return 0;
16256
16528
  }
@@ -16925,6 +17197,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
16925
17197
 
16926
17198
  // src/server/runner/tick.ts
16927
17199
  var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
17200
+ var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
16928
17201
  async function runTick(ctx) {
16929
17202
  try {
16930
17203
  await ctx.collections.health.updateOne(
@@ -16965,12 +17238,18 @@ async function runTick(ctx) {
16965
17238
  await processScheduledBroadcasts2(ctx).catch((err) => {
16966
17239
  console.error("mailery: broadcast dispatch failed", err);
16967
17240
  });
17241
+ await resumeStalledBroadcasts(ctx).catch((err) => {
17242
+ console.error("mailery: stalled-broadcast resume failed", err);
17243
+ });
16968
17244
  await evaluateHealth(ctx).catch((err) => {
16969
17245
  console.error("mailery: health evaluation failed", err);
16970
17246
  });
16971
17247
  await promoteSoftBounces(ctx).catch((err) => {
16972
17248
  console.error("mailery: soft-bounce promotion failed", err);
16973
17249
  });
17250
+ await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
17251
+ console.error("mailery: stranded-webhook drain failed", err);
17252
+ });
16974
17253
  await Promise.all([
16975
17254
  runDnsblChecks(ctx).catch((err) => {
16976
17255
  console.error("mailery: dnsbl checks failed", err);
@@ -17041,140 +17320,6 @@ async function processScheduledBroadcasts2(ctx) {
17041
17320
  await processScheduledBroadcasts(ctx);
17042
17321
  }
17043
17322
 
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
17323
  // src/server/mailer.ts
17179
17324
  var Mailer = class _Mailer {
17180
17325
  db;
@@ -17214,6 +17359,7 @@ var Mailer = class _Mailer {
17214
17359
  * MAILER_MONGODB_URI — Mongo connection string (required)
17215
17360
  * MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
17216
17361
  * MAILER_REDIS_URL — Redis connection URL (required)
17362
+ * MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
17217
17363
  * MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
17218
17364
  * MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
17219
17365
  * MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
@@ -17262,7 +17408,11 @@ var Mailer = class _Mailer {
17262
17408
  }
17263
17409
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
17264
17410
  const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
17265
- const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
17411
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
17412
+ driver: "bull",
17413
+ redis: { url: required("MAILER_REDIS_URL") },
17414
+ prefix: env.MAILER_QUEUE_PREFIX
17415
+ };
17266
17416
  return _Mailer.init({
17267
17417
  db,
17268
17418
  adapter,
@@ -17639,41 +17789,48 @@ var Mailer = class _Mailer {
17639
17789
  if (existing) return { sendId: String(existing._id) };
17640
17790
  const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
17641
17791
  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
- });
17792
+ try {
17793
+ await this.collections.sends.insertOne({
17794
+ _id: sendId,
17795
+ dedupeKey,
17796
+ externalId: parsed.externalId,
17797
+ emailAtSend: contact.email,
17798
+ templateId: template._id,
17799
+ templateSlug: template.slug,
17800
+ flowRunId: null,
17801
+ broadcastId: null,
17802
+ manualSendBy: "sendOneOff",
17803
+ kind: template.kind,
17804
+ provider: providerName,
17805
+ providerMessageId: null,
17806
+ fromName: template.fromName,
17807
+ fromEmail: template.fromEmail,
17808
+ subject: template.subject,
17809
+ bodyHash: "",
17810
+ status: "queued",
17811
+ errorMessage: null,
17812
+ bounceType: null,
17813
+ bounceReason: null,
17814
+ links: [],
17815
+ vars: parsed.vars ?? {},
17816
+ openedAt: null,
17817
+ openCount: 0,
17818
+ firstClickAt: null,
17819
+ clickCount: 0,
17820
+ clickedLinks: [],
17821
+ unsubscribedAt: null,
17822
+ complainedAt: null,
17823
+ queuedAt: /* @__PURE__ */ new Date(),
17824
+ updatedAt: /* @__PURE__ */ new Date(),
17825
+ sentAt: null,
17826
+ deliveredAt: null
17827
+ });
17828
+ } catch (err) {
17829
+ if (err?.code !== 11e3) throw err;
17830
+ const winner = await this.collections.sends.findOne({ dedupeKey });
17831
+ if (winner) return { sendId: String(winner._id) };
17832
+ throw err;
17833
+ }
17677
17834
  await this.queues.send.add(
17678
17835
  "send",
17679
17836
  { sendId: String(sendId) },
@@ -17717,7 +17874,12 @@ var Mailer = class _Mailer {
17717
17874
  await runTick(this.runnerContext);
17718
17875
  },
17719
17876
  advance: async (data) => {
17720
- if (!ObjectId.isValid(data.flowRunId)) return;
17877
+ if (data.broadcastId) {
17878
+ if (!ObjectId.isValid(data.broadcastId)) return;
17879
+ await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
17880
+ return;
17881
+ }
17882
+ if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
17721
17883
  await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
17722
17884
  },
17723
17885
  send: async (data) => {
@@ -17733,27 +17895,7 @@ var Mailer = class _Mailer {
17733
17895
  }
17734
17896
  /** Process unprocessed webhook events in mailer_webhook_events. */
17735
17897
  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
- }
17898
+ await processWebhookBacklog(this.runnerContext);
17757
17899
  }
17758
17900
  async stop() {
17759
17901
  await this.queueDriver.close();