mailery 0.17.1 → 0.18.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
@@ -119,6 +119,42 @@ __export(mongo_exports, {
119
119
  function canBeObjectId(s) {
120
120
  return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
121
121
  }
122
+ function getPath(doc, path2) {
123
+ let cur = doc;
124
+ for (const part of path2.split(".")) {
125
+ if (cur === null || typeof cur !== "object") return void 0;
126
+ cur = cur[part];
127
+ }
128
+ return cur;
129
+ }
130
+ function isObjectIdLike(v) {
131
+ return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
132
+ }
133
+ function packScalar(v) {
134
+ if (v instanceof Date) return { d: v.toISOString() };
135
+ if (isObjectIdLike(v)) return { o: v.toHexString() };
136
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
137
+ return null;
138
+ }
139
+ function unpackScalar(v) {
140
+ if (v && typeof v === "object") {
141
+ if ("d" in v) return new Date(v.d);
142
+ if ("o" in v) return new ObjectId(v.o);
143
+ }
144
+ return v;
145
+ }
146
+ function encodeSortCursor(value, id) {
147
+ return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
148
+ }
149
+ function decodeSortCursor(cursor) {
150
+ try {
151
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
152
+ if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
153
+ return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
122
158
  var MongoContactAdapter;
123
159
  var init_mongo = __esm({
124
160
  "src/server/adapters/mongo.ts"() {
@@ -168,7 +204,10 @@ var init_mongo = __esm({
168
204
  }
169
205
  return out;
170
206
  }
207
+ /** `query` honours `opts.sort` — see `querySorted`. */
208
+ supportsSort = true;
171
209
  async query(filter, opts) {
210
+ if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
172
211
  const query = this.translateFilterFn(filter);
173
212
  const limit = Math.min(opts.limit, this.batchSize);
174
213
  if (opts.cursor) {
@@ -187,6 +226,52 @@ var init_mongo = __esm({
187
226
  const query = this.translateFilterFn(filter);
188
227
  return await this.col.countDocuments(query);
189
228
  }
229
+ /**
230
+ * Keyset pagination in (sort.field, idField) order. The cursor encodes the
231
+ * last row's sort value and id, and the next page is every row strictly
232
+ * after that position. Mongo sorts a null or missing value lowest, so
233
+ * `desc` puts contacts without the field last and `asc` puts them first;
234
+ * the position predicate follows the same rule, so they are neither
235
+ * skipped nor repeated.
236
+ *
237
+ * The field should hold one BSON type (or be absent): range operators only
238
+ * compare within a type. A value that moves while a pass is paging (a
239
+ * contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
240
+ * at all in that pass; broadcast dispatch tolerates both (the per-recipient
241
+ * dedupe key stops a second send, and a later pass picks up a skipped one).
242
+ * Index `{ <field>: -1, <idField>: 1 }` on a large collection.
243
+ */
244
+ async querySorted(filter, limitIn, cursor, sort) {
245
+ const base = this.translateFilterFn(filter);
246
+ const limit = Math.min(limitIn, this.batchSize);
247
+ const dir = sort.direction === "desc" ? -1 : 1;
248
+ let query = base;
249
+ if (cursor) {
250
+ const pos = decodeSortCursor(cursor);
251
+ if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
252
+ query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
253
+ }
254
+ const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
255
+ const hasMore = docs.length > limit;
256
+ const slice = hasMore ? docs.slice(0, limit) : docs;
257
+ const last = slice[slice.length - 1];
258
+ return {
259
+ contacts: slice.map((d) => this.toContactFn(d)),
260
+ nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
261
+ };
262
+ }
263
+ afterPosition(field, dir, value, id) {
264
+ const idF = this.idField;
265
+ const isNull = value === null || value === void 0;
266
+ if (dir === -1) {
267
+ if (isNull) return { [field]: null, [idF]: { $gt: id } };
268
+ return {
269
+ $or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
270
+ };
271
+ }
272
+ if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
273
+ return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
274
+ }
190
275
  addTags;
191
276
  removeTags;
192
277
  // -------------------------------------------------------------------------
@@ -1926,9 +2011,9 @@ https://typegoose.github.io/mongodb-memory-server/docs/guides/migration/migrate7
1926
2011
  exports.AssertionFallbackError = AssertionFallbackError;
1927
2012
  var ReplsetCountLowError = class extends Error {
1928
2013
  count;
1929
- constructor(count) {
1930
- super(`ReplSet Count needs to be 1 or higher! (specified count: "${count}")`);
1931
- this.count = count;
2014
+ constructor(count2) {
2015
+ super(`ReplSet Count needs to be 1 or higher! (specified count: "${count2}")`);
2016
+ this.count = count2;
1932
2017
  }
1933
2018
  };
1934
2019
  exports.ReplsetCountLowError = ReplsetCountLowError;
@@ -3260,20 +3345,20 @@ var require_range = __commonJS({
3260
3345
  }
3261
3346
  return `${from} ${to}`.trim();
3262
3347
  };
3263
- var testSet = (set, version, options2) => {
3264
- for (let i = 0; i < set.length; i++) {
3265
- if (!set[i].test(version)) {
3348
+ var testSet = (set2, version, options2) => {
3349
+ for (let i = 0; i < set2.length; i++) {
3350
+ if (!set2[i].test(version)) {
3266
3351
  return false;
3267
3352
  }
3268
3353
  }
3269
3354
  if (version.prerelease.length && !options2.includePrerelease) {
3270
- for (let i = 0; i < set.length; i++) {
3271
- debug(set[i].semver);
3272
- if (set[i].semver === Comparator.ANY) {
3355
+ for (let i = 0; i < set2.length; i++) {
3356
+ debug(set2[i].semver);
3357
+ if (set2[i].semver === Comparator.ANY) {
3273
3358
  continue;
3274
3359
  }
3275
- if (set[i].semver.prerelease.length > 0) {
3276
- const allowed = set[i].semver;
3360
+ if (set2[i].semver.prerelease.length > 0) {
3361
+ const allowed = set2[i].semver;
3277
3362
  if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
3278
3363
  return true;
3279
3364
  }
@@ -3657,7 +3742,7 @@ var require_simplify = __commonJS({
3657
3742
  var satisfies = require_satisfies();
3658
3743
  var compare = require_compare();
3659
3744
  module.exports = (versions, range, options2) => {
3660
- const set = [];
3745
+ const set2 = [];
3661
3746
  let first = null;
3662
3747
  let prev = null;
3663
3748
  const v = versions.sort((a, b) => compare(a, b, options2));
@@ -3670,17 +3755,17 @@ var require_simplify = __commonJS({
3670
3755
  }
3671
3756
  } else {
3672
3757
  if (prev) {
3673
- set.push([first, prev]);
3758
+ set2.push([first, prev]);
3674
3759
  }
3675
3760
  prev = null;
3676
3761
  first = null;
3677
3762
  }
3678
3763
  }
3679
3764
  if (first) {
3680
- set.push([first, null]);
3765
+ set2.push([first, null]);
3681
3766
  }
3682
3767
  const ranges = [];
3683
- for (const [min, max] of set) {
3768
+ for (const [min, max] of set2) {
3684
3769
  if (min === max) {
3685
3770
  ranges.push(min);
3686
3771
  } else if (!max && min === v[0]) {
@@ -10657,18 +10742,18 @@ var require_semver3 = __commonJS({
10657
10742
  range = range.replace(safeRe[t.CARETTRIM], caretTrimReplace);
10658
10743
  range = range.split(/\s+/).join(" ");
10659
10744
  var compRe = loose ? safeRe[t.COMPARATORLOOSE] : safeRe[t.COMPARATOR];
10660
- var set = range.split(" ").map(function(comp) {
10745
+ var set2 = range.split(" ").map(function(comp) {
10661
10746
  return parseComparator(comp, this.options);
10662
10747
  }, this).join(" ").split(/\s+/);
10663
10748
  if (this.options.loose) {
10664
- set = set.filter(function(comp) {
10749
+ set2 = set2.filter(function(comp) {
10665
10750
  return !!comp.match(compRe);
10666
10751
  });
10667
10752
  }
10668
- set = set.map(function(comp) {
10753
+ set2 = set2.map(function(comp) {
10669
10754
  return new Comparator(comp, this.options);
10670
10755
  }, this);
10671
- return set;
10756
+ return set2;
10672
10757
  };
10673
10758
  Range.prototype.intersects = function(range, options2) {
10674
10759
  if (!(range instanceof Range)) {
@@ -10896,20 +10981,20 @@ var require_semver3 = __commonJS({
10896
10981
  }
10897
10982
  return false;
10898
10983
  };
10899
- function testSet(set, version, options2) {
10900
- for (var i2 = 0; i2 < set.length; i2++) {
10901
- if (!set[i2].test(version)) {
10984
+ function testSet(set2, version, options2) {
10985
+ for (var i2 = 0; i2 < set2.length; i2++) {
10986
+ if (!set2[i2].test(version)) {
10902
10987
  return false;
10903
10988
  }
10904
10989
  }
10905
10990
  if (version.prerelease.length && !options2.includePrerelease) {
10906
- for (i2 = 0; i2 < set.length; i2++) {
10907
- debug(set[i2].semver);
10908
- if (set[i2].semver === ANY) {
10991
+ for (i2 = 0; i2 < set2.length; i2++) {
10992
+ debug(set2[i2].semver);
10993
+ if (set2[i2].semver === ANY) {
10909
10994
  continue;
10910
10995
  }
10911
- if (set[i2].semver.prerelease.length > 0) {
10912
- var allowed = set[i2].semver;
10996
+ if (set2[i2].semver.prerelease.length > 0) {
10997
+ var allowed = set2[i2].semver;
10913
10998
  if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
10914
10999
  return true;
10915
11000
  }
@@ -14328,6 +14413,8 @@ var sendOneOffInputSchema = z.object({
14328
14413
  providerOverride: z.string().optional(),
14329
14414
  dedupeKey: z.string().min(1).max(512)
14330
14415
  });
14416
+ z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
14417
+ z.union([z.string(), z.number(), z.boolean(), z.null()]);
14331
14418
  var flowStepSchema = z.lazy(
14332
14419
  () => z.discriminatedUnion("type", [
14333
14420
  z.object({
@@ -14496,6 +14583,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
14496
14583
  windowMinutes: 60,
14497
14584
  minSendsBeforeEval: 100
14498
14585
  };
14586
+ var BROADCAST_STOP_RULE_DEFAULTS = {
14587
+ enabled: true,
14588
+ hardBounceRatePct: 2,
14589
+ complaintRatePct: 0.1,
14590
+ unsubscribeRatePct: 1,
14591
+ minSample: 100
14592
+ };
14499
14593
  function resolveConfig(c) {
14500
14594
  return {
14501
14595
  ...c,
@@ -14523,7 +14617,8 @@ function resolveConfig(c) {
14523
14617
  storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
14524
14618
  requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
14525
14619
  trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
14526
- circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
14620
+ circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
14621
+ broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
14527
14622
  };
14528
14623
  }
14529
14624
 
@@ -14741,11 +14836,13 @@ function resolveProvider(providers, name) {
14741
14836
  function registeredProviderNames(providers) {
14742
14837
  return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
14743
14838
  }
14839
+ var SEND_ID_RE = /^[a-f0-9]{24}$/i;
14744
14840
  function signUnsubscribeToken(payload, secret) {
14745
14841
  const body = JSON.stringify({
14746
14842
  e: payload.email.toLowerCase(),
14747
14843
  s: payload.scope,
14748
- x: payload.expiresAt.getTime()
14844
+ x: payload.expiresAt.getTime(),
14845
+ ...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
14749
14846
  });
14750
14847
  const bodyB64 = b64url(Buffer.from(body, "utf8"));
14751
14848
  const hmac = crypto2.createHmac("sha256", secret).update(bodyB64).digest();
@@ -15449,6 +15546,179 @@ async function getBucketStatus(ctx, fromEmail, kind) {
15449
15546
  const id = healthBucketId(domain, kind);
15450
15547
  return ctx.collections.health.findOne({ _id: id });
15451
15548
  }
15549
+ function emptyBroadcastStats() {
15550
+ return withRates({
15551
+ total: 0,
15552
+ accepted: 0,
15553
+ delivered: 0,
15554
+ bounced: 0,
15555
+ hardBounced: 0,
15556
+ softBounced: 0,
15557
+ complained: 0,
15558
+ unsubscribed: 0,
15559
+ opened: 0,
15560
+ clicked: 0,
15561
+ outcomes: 0
15562
+ });
15563
+ }
15564
+ var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
15565
+ function withRates(c) {
15566
+ return {
15567
+ ...c,
15568
+ rates: {
15569
+ deliveryRatePct: pct(c.delivered, c.accepted),
15570
+ bounceRatePct: pct(c.bounced, c.outcomes),
15571
+ hardBounceRatePct: pct(c.hardBounced, c.outcomes),
15572
+ complaintRatePct: pct(c.complained, c.outcomes),
15573
+ unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
15574
+ openRatePct: pct(c.opened, c.delivered),
15575
+ clickRatePct: pct(c.clicked, c.delivered)
15576
+ }
15577
+ };
15578
+ }
15579
+ var set = (field) => ({ $ifNull: [field, false] });
15580
+ var DELIVERED = {
15581
+ $or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
15582
+ };
15583
+ var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
15584
+ var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
15585
+ async function aggregateBroadcastStats(collections, broadcastId) {
15586
+ const rows = await collections.sends.aggregate([
15587
+ { $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
15588
+ {
15589
+ $group: {
15590
+ _id: "$broadcastId",
15591
+ total: { $sum: 1 },
15592
+ accepted: count(set("$sentAt")),
15593
+ delivered: count(DELIVERED),
15594
+ bounced: count(BOUNCED),
15595
+ hardBounced: count({ $eq: ["$bounceType", "hard"] }),
15596
+ softBounced: count({ $eq: ["$bounceType", "soft"] }),
15597
+ complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
15598
+ unsubscribed: count(set("$unsubscribedAt")),
15599
+ opened: count(set("$openedAt")),
15600
+ clicked: count(set("$firstClickAt")),
15601
+ outcomes: count({ $or: [DELIVERED, BOUNCED] })
15602
+ }
15603
+ }
15604
+ ]).toArray();
15605
+ const out = /* @__PURE__ */ new Map();
15606
+ for (const { _id, ...c } of rows) {
15607
+ if (_id) out.set(String(_id), withRates(c));
15608
+ }
15609
+ return out;
15610
+ }
15611
+ async function broadcastStatsFor(collections, broadcastId) {
15612
+ return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
15613
+ }
15614
+ function effectiveStopRules(config, b) {
15615
+ return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
15616
+ }
15617
+ var RULE_COUNTS = [
15618
+ ["complaintRatePct", "complained"],
15619
+ ["hardBounceRatePct", "hardBounced"],
15620
+ ["unsubscribeRatePct", "unsubscribed"]
15621
+ ];
15622
+ function evaluateStopRules(stats, rules) {
15623
+ const sample = stats.outcomes;
15624
+ const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
15625
+ const breaches = [];
15626
+ if (evaluated) {
15627
+ for (const [rule, key] of RULE_COUNTS) {
15628
+ const n = stats[key];
15629
+ const exactPct = n / sample * 100;
15630
+ if (exactPct > rules[rule]) {
15631
+ breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
15632
+ }
15633
+ }
15634
+ }
15635
+ return { rules, sample, evaluated, breaches };
15636
+ }
15637
+ var RULE_LABEL = {
15638
+ complaintRatePct: "complaint rate",
15639
+ hardBounceRatePct: "hard bounce rate",
15640
+ unsubscribeRatePct: "unsubscribe rate"
15641
+ };
15642
+ var PAUSE_PRECEDENCE = {
15643
+ manual: 1,
15644
+ cap_reached: 1,
15645
+ stop_rule: 2,
15646
+ circuit_breaker: 2
15647
+ };
15648
+ async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
15649
+ const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
15650
+ (k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
15651
+ );
15652
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
15653
+ {
15654
+ _id: broadcastId,
15655
+ $or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
15656
+ },
15657
+ { $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
15658
+ { returnDocument: "after" }
15659
+ );
15660
+ if (!b) return { paused: false, heldSends: 0 };
15661
+ const held = await holdQueuedSends(ctx, broadcastId, reason.code);
15662
+ if (ctx.audit) {
15663
+ await ctx.audit({
15664
+ actor,
15665
+ action: "broadcast.pause",
15666
+ resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
15667
+ diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
15668
+ }).catch(() => {
15669
+ });
15670
+ }
15671
+ if (ctx.config.onBroadcastPaused) {
15672
+ try {
15673
+ await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
15674
+ } catch {
15675
+ }
15676
+ }
15677
+ return { paused: true, heldSends: held };
15678
+ }
15679
+ async function holdQueuedSends(ctx, broadcastId, code) {
15680
+ const res = await ctx.collections.sends.updateMany(
15681
+ { broadcastId, status: "queued" },
15682
+ { $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
15683
+ );
15684
+ return res.modifiedCount;
15685
+ }
15686
+ async function evaluateBroadcastStopRules(ctx, broadcastId) {
15687
+ const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
15688
+ if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
15689
+ if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
15690
+ const stats = await broadcastStatsFor(ctx.collections, broadcastId);
15691
+ const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
15692
+ if (evaluation.breaches.length === 0) return evaluation;
15693
+ const now = /* @__PURE__ */ new Date();
15694
+ if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
15695
+ await ctx.collections.broadcasts.updateOne(
15696
+ { _id: broadcastId, stopRuleBreach: { $in: [null] } },
15697
+ { $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
15698
+ );
15699
+ return evaluation;
15700
+ }
15701
+ const first = evaluation.breaches[0];
15702
+ await pauseBroadcast(ctx, broadcastId, {
15703
+ code: "stop_rule",
15704
+ message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
15705
+ at: now,
15706
+ details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
15707
+ });
15708
+ return evaluation;
15709
+ }
15710
+ var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
15711
+ async function evaluateActiveBroadcastStopRules(ctx) {
15712
+ const live = await ctx.collections.broadcasts.find(
15713
+ { status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
15714
+ { projection: { _id: 1 } }
15715
+ ).limit(200).toArray();
15716
+ for (const { _id } of live) {
15717
+ await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
15718
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
15719
+ });
15720
+ }
15721
+ }
15452
15722
 
15453
15723
  // src/server/runner/webhook.ts
15454
15724
  function dimsFromSend(send) {
@@ -15599,6 +15869,11 @@ async function applyWebhookEvent(event, ctx) {
15599
15869
  );
15600
15870
  break;
15601
15871
  }
15872
+ if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
15873
+ await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
15874
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
15875
+ });
15876
+ }
15602
15877
  }
15603
15878
  async function suppressOnce(ctx, email, reason, scope) {
15604
15879
  const normalized = email.toLowerCase();
@@ -15996,11 +16271,30 @@ async function isSuppressed(collections, email, kind) {
15996
16271
  if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
15997
16272
  const hashed = await collections.suppressions.findOne({
15998
16273
  emailHash: sha256Hex(normalized),
15999
- scope: { $in: allowed }
16274
+ scope: { $in: allowed },
16275
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
16000
16276
  });
16001
16277
  if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
16002
16278
  return { suppressed: false };
16003
16279
  }
16280
+ async function suppressedEmails(collections, emails, kind) {
16281
+ const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
16282
+ if (normalized.length === 0) return /* @__PURE__ */ new Set();
16283
+ const allowed = SCOPES_BY_KIND[kind];
16284
+ const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
16285
+ const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
16286
+ const [plain, hashed] = await Promise.all([
16287
+ collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
16288
+ collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
16289
+ ]);
16290
+ const out = /* @__PURE__ */ new Set();
16291
+ for (const e of plain) if (typeof e === "string") out.add(e);
16292
+ for (const h of hashed) {
16293
+ const e = byHash.get(String(h));
16294
+ if (e) out.add(e);
16295
+ }
16296
+ return out;
16297
+ }
16004
16298
 
16005
16299
  // src/server/runner/send.ts
16006
16300
  async function handleSend(run, step2, contact, flow, ctx) {
@@ -16077,6 +16371,27 @@ async function dispatchSend(sendId, ctx) {
16077
16371
  await markFailed(send._id, "template_missing", ctx);
16078
16372
  return;
16079
16373
  }
16374
+ if (send.broadcastId) {
16375
+ const broadcast = await ctx.collections.broadcasts.findOne(
16376
+ { _id: send.broadcastId },
16377
+ { projection: { status: 1, pauseReason: 1 } }
16378
+ );
16379
+ const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
16380
+ if (holding || broadcast?.status === "cancelled") {
16381
+ const held = holding;
16382
+ await ctx.collections.sends.updateOne(
16383
+ { _id: send._id },
16384
+ {
16385
+ $set: {
16386
+ status: held ? "held" : "cancelled",
16387
+ errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
16388
+ updatedAt: /* @__PURE__ */ new Date()
16389
+ }
16390
+ }
16391
+ );
16392
+ return;
16393
+ }
16394
+ }
16080
16395
  const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
16081
16396
  if (supp.suppressed) {
16082
16397
  await ctx.collections.sends.updateOne(
@@ -16087,6 +16402,19 @@ async function dispatchSend(sendId, ctx) {
16087
16402
  }
16088
16403
  if (send.kind === "marketing") {
16089
16404
  const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
16405
+ if (bucket?.status === "tripped" && send.broadcastId) {
16406
+ await ctx.collections.sends.updateOne(
16407
+ { _id: send._id },
16408
+ { $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
16409
+ );
16410
+ await pauseBroadcast(ctx, send.broadcastId, {
16411
+ code: "circuit_breaker",
16412
+ message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
16413
+ at: /* @__PURE__ */ new Date(),
16414
+ details: { bucket: bucket._id }
16415
+ });
16416
+ return;
16417
+ }
16090
16418
  if (bucket?.status === "tripped") {
16091
16419
  await ctx.collections.sends.updateOne(
16092
16420
  { _id: send._id },
@@ -16119,7 +16447,7 @@ async function dispatchSend(sendId, ctx) {
16119
16447
  eventName: run?.triggerEvent?.name,
16120
16448
  eventProperties: run?.triggerEvent?.properties
16121
16449
  });
16122
- renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
16450
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
16123
16451
  rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
16124
16452
  } catch (err) {
16125
16453
  await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
@@ -16210,11 +16538,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
16210
16538
  }
16211
16539
  return ctx.config.defaultProvider;
16212
16540
  }
16213
- function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
16541
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
16214
16542
  const scope = "marketing";
16215
16543
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
16216
16544
  const token = signUnsubscribeToken(
16217
- { email: contact.email, scope, expiresAt },
16545
+ { email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
16218
16546
  ctx.config.unsubscribeSecret
16219
16547
  );
16220
16548
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
@@ -16590,6 +16918,184 @@ async function sweepStrandedFlowRuns(ctx) {
16590
16918
  }
16591
16919
  }
16592
16920
  }
16921
+
16922
+ // src/server/runner/segment.ts
16923
+ function planSegment(seg) {
16924
+ const hostFilter = {};
16925
+ const postFilters = [];
16926
+ const hostFields = /* @__PURE__ */ new Set();
16927
+ const takeField = (field) => {
16928
+ if (hostFields.has(field)) return false;
16929
+ hostFields.add(field);
16930
+ return true;
16931
+ };
16932
+ for (const f of seg.filters ?? []) {
16933
+ switch (f.kind) {
16934
+ case "hasTag":
16935
+ if (hostFilter.hasTag === void 0) {
16936
+ hostFilter.hasTag = f.tag;
16937
+ continue;
16938
+ }
16939
+ break;
16940
+ case "fieldEquals":
16941
+ if (!hostFilter.fieldEquals && takeField(f.field)) {
16942
+ hostFilter.fieldEquals = { field: f.field, value: f.value };
16943
+ continue;
16944
+ }
16945
+ break;
16946
+ case "fieldIn":
16947
+ if (!hostFilter.fieldIn && takeField(f.field)) {
16948
+ hostFilter.fieldIn = { field: f.field, values: f.values };
16949
+ continue;
16950
+ }
16951
+ break;
16952
+ case "fieldExists":
16953
+ if (!hostFilter.fieldExists && takeField(f.field)) {
16954
+ hostFilter.fieldExists = f.field;
16955
+ continue;
16956
+ }
16957
+ break;
16958
+ }
16959
+ postFilters.push(f);
16960
+ }
16961
+ return { hostFilter, postFilters };
16962
+ }
16963
+ async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
16964
+ if (filters.length === 0 || contacts.length === 0) return contacts;
16965
+ const externalIds = contacts.map((c) => c.externalId);
16966
+ const leaves = /* @__PURE__ */ new Map();
16967
+ collectLookups(filters, leaves);
16968
+ const cache = /* @__PURE__ */ new Map();
16969
+ await Promise.all(
16970
+ [...leaves].map(async ([key, f]) => {
16971
+ cache.set(key, await lookup(f, externalIds, ctx, now));
16972
+ })
16973
+ );
16974
+ return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
16975
+ }
16976
+ function lookupKey(f) {
16977
+ switch (f.kind) {
16978
+ case "subscriptionStatus":
16979
+ return `sub:${f.equals}`;
16980
+ case "firedEvent":
16981
+ case "notFiredEvent":
16982
+ return `evt:${f.withinDays ?? ""}:${f.eventName}`;
16983
+ case "opened":
16984
+ case "notOpened":
16985
+ return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
16986
+ case "subscribedAfter":
16987
+ return `subAfter:${toDate(f.date).getTime()}`;
16988
+ case "subscribedBefore":
16989
+ return `subBefore:${toDate(f.date).getTime()}`;
16990
+ default:
16991
+ return null;
16992
+ }
16993
+ }
16994
+ function collectLookups(filters, out) {
16995
+ for (const f of filters) {
16996
+ if (f.kind === "any") collectLookups(f.filters, out);
16997
+ else if (f.kind === "not") collectLookups([f.filter], out);
16998
+ else {
16999
+ const key = lookupKey(f);
17000
+ if (key && !out.has(key)) out.set(key, f);
17001
+ }
17002
+ }
17003
+ }
17004
+ async function lookup(f, externalIds, ctx, now) {
17005
+ const c = ctx.collections;
17006
+ const inPage = { $in: externalIds };
17007
+ const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
17008
+ let ids;
17009
+ switch (f.kind) {
17010
+ case "subscriptionStatus":
17011
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
17012
+ break;
17013
+ case "firedEvent":
17014
+ case "notFiredEvent": {
17015
+ const cutoff = since(f.withinDays);
17016
+ ids = await c.events.distinct("externalId", {
17017
+ externalId: inPage,
17018
+ name: f.eventName,
17019
+ ...cutoff ? { occurredAt: { $gt: cutoff } } : {}
17020
+ });
17021
+ break;
17022
+ }
17023
+ case "opened":
17024
+ case "notOpened": {
17025
+ const cutoff = since(f.withinDays);
17026
+ ids = await c.sends.distinct("externalId", {
17027
+ externalId: inPage,
17028
+ openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
17029
+ ...f.templateSlug ? { templateSlug: f.templateSlug } : {}
17030
+ });
17031
+ break;
17032
+ }
17033
+ case "subscribedAfter":
17034
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
17035
+ break;
17036
+ case "subscribedBefore":
17037
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
17038
+ break;
17039
+ default:
17040
+ ids = [];
17041
+ }
17042
+ return new Set(ids.map(String));
17043
+ }
17044
+ function matches(c, f, cache) {
17045
+ switch (f.kind) {
17046
+ case "subscriptionStatus":
17047
+ case "firedEvent":
17048
+ case "opened":
17049
+ case "subscribedAfter":
17050
+ case "subscribedBefore":
17051
+ return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
17052
+ case "notFiredEvent":
17053
+ case "notOpened":
17054
+ return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
17055
+ case "hasTag":
17056
+ return c.tags.includes(f.tag);
17057
+ case "notHasTag":
17058
+ return !c.tags.includes(f.tag);
17059
+ case "fieldEquals":
17060
+ return valuesEqual(fieldValue(c, f.field), f.value);
17061
+ case "fieldIn": {
17062
+ const v = fieldValue(c, f.field);
17063
+ return f.values.some((x) => valuesEqual(v, x));
17064
+ }
17065
+ case "fieldExists":
17066
+ return fieldValue(c, f.field) !== void 0;
17067
+ case "any":
17068
+ return f.filters.some((sub) => matches(c, sub, cache));
17069
+ case "not":
17070
+ return !matches(c, f.filter, cache);
17071
+ default:
17072
+ throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
17073
+ }
17074
+ }
17075
+ function fieldValue(c, path2) {
17076
+ let cur = c.fields;
17077
+ for (const part of path2.split(".")) {
17078
+ if (cur === null || typeof cur !== "object") return void 0;
17079
+ cur = cur[part];
17080
+ }
17081
+ return cur;
17082
+ }
17083
+ function valuesEqual(a, b) {
17084
+ if (a instanceof Date || b instanceof Date) {
17085
+ const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
17086
+ const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
17087
+ return !Number.isNaN(ta) && ta === tb;
17088
+ }
17089
+ if (a && typeof a === "object" && typeof a.toHexString === "function") {
17090
+ return String(a) === String(b);
17091
+ }
17092
+ return a === b;
17093
+ }
17094
+ function toDate(d) {
17095
+ return d instanceof Date ? d : new Date(d);
17096
+ }
17097
+
17098
+ // src/server/runner/broadcasts.ts
16593
17099
  var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
16594
17100
  async function processScheduledBroadcasts(ctx) {
16595
17101
  const now = /* @__PURE__ */ new Date();
@@ -16597,7 +17103,7 @@ async function processScheduledBroadcasts(ctx) {
16597
17103
  for (const b of due) {
16598
17104
  const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
16599
17105
  { _id: b._id, status: "scheduled" },
16600
- { $set: { status: "sending", startedAt: now, updatedAt: now } },
17106
+ { $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
16601
17107
  { returnDocument: "after" }
16602
17108
  );
16603
17109
  if (!claimed) continue;
@@ -16605,8 +17111,14 @@ async function processScheduledBroadcasts(ctx) {
16605
17111
  }
16606
17112
  }
16607
17113
  async function startBroadcastDispatch(broadcast, ctx) {
17114
+ const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
17115
+ { _id: broadcast._id },
17116
+ { $inc: { dispatchGeneration: 1 } },
17117
+ { returnDocument: "after" }
17118
+ );
17119
+ const current = bumped ?? broadcast;
16608
17120
  if (ctx.config.queue.driver === "noop") {
16609
- await runBroadcastDispatch(broadcast, ctx);
17121
+ await runBroadcastDispatch(current, ctx);
16610
17122
  return;
16611
17123
  }
16612
17124
  await ctx.queues.advance.add(
@@ -16615,7 +17127,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
16615
17127
  {
16616
17128
  attempts: 3,
16617
17129
  backoff: { type: "exponential", delay: 6e4 },
16618
- jobId: `broadcast-dispatch:${broadcast._id}`
17130
+ jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
16619
17131
  }
16620
17132
  );
16621
17133
  }
@@ -16628,10 +17140,11 @@ async function resumeStalledBroadcasts(ctx) {
16628
17140
  const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
16629
17141
  const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
16630
17142
  for (const b of stalled) {
16631
- await ctx.collections.broadcasts.updateOne(
16632
- { _id: b._id },
16633
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
17143
+ const reset = await ctx.collections.broadcasts.updateOne(
17144
+ { _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
17145
+ { $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
16634
17146
  );
17147
+ if (reset.modifiedCount === 0) continue;
16635
17148
  await startBroadcastDispatch(b, ctx);
16636
17149
  }
16637
17150
  }
@@ -16641,50 +17154,117 @@ async function runBroadcastDispatch(broadcast, ctx) {
16641
17154
  } catch (err) {
16642
17155
  console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
16643
17156
  await ctx.collections.broadcasts.updateOne(
16644
- { _id: broadcast._id },
16645
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
17157
+ { _id: broadcast._id, status: "sending" },
17158
+ {
17159
+ $set: {
17160
+ status: "failed",
17161
+ failureReason: `dispatch error: ${String(err?.message ?? err)}`,
17162
+ updatedAt: /* @__PURE__ */ new Date(),
17163
+ dispatchLeaseId: null
17164
+ }
17165
+ }
16646
17166
  );
16647
17167
  }
16648
17168
  }
17169
+ function broadcastDedupeKey(broadcastId, externalId) {
17170
+ return `broadcast:${broadcastId}:${externalId}`;
17171
+ }
17172
+ var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17173
+ function isSendableEmail(email) {
17174
+ return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
17175
+ }
17176
+ async function* eligibleRecipientPages(broadcast, kind, ctx) {
17177
+ const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
17178
+ const sort = broadcast.order ?? void 0;
17179
+ if (sort && !ctx.adapter.supportsSort) {
17180
+ throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
17181
+ }
17182
+ let cursor;
17183
+ for (; ; ) {
17184
+ const page = await ctx.adapter.query(hostFilter, {
17185
+ limit: ctx.config.broadcastEnqueueBatchSize,
17186
+ cursor,
17187
+ ...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
17188
+ });
17189
+ if (page.contacts.length === 0) break;
17190
+ const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
17191
+ const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
17192
+ yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
17193
+ if (!page.nextCursor) break;
17194
+ cursor = page.nextCursor;
17195
+ }
17196
+ }
17197
+ async function alreadyDispatched(ctx, broadcastId, contacts) {
17198
+ if (contacts.length === 0) return /* @__PURE__ */ new Set();
17199
+ const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
17200
+ const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
17201
+ return new Set(found.map(String));
17202
+ }
16649
17203
  async function dispatchBroadcast(broadcast, ctx) {
16650
17204
  const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
16651
- if (!template) {
17205
+ const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
17206
+ if (failure || !template) {
16652
17207
  await ctx.collections.broadcasts.updateOne(
16653
- { _id: broadcast._id },
16654
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
17208
+ { _id: broadcast._id, status: "sending" },
17209
+ { $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
16655
17210
  );
16656
17211
  return;
16657
17212
  }
16658
- const hostFilter = toAdapterFilter(broadcast.segmentDefinition);
16659
- const postFilters = broadcast.segmentDefinition.filters.filter((f) => isMailerSide(f));
16660
- let cursor = void 0;
16661
- let total = 0;
16662
- const batchSize = ctx.config.broadcastEnqueueBatchSize;
17213
+ const leaseId = new ObjectId().toHexString();
17214
+ const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
17215
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
17216
+ {
17217
+ _id: broadcast._id,
17218
+ status: "sending",
17219
+ $or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
17220
+ },
17221
+ { $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
17222
+ { returnDocument: "after" }
17223
+ );
17224
+ if (!b) return;
17225
+ const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
17226
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
17227
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
17228
+ )).matchedCount === 1;
16663
17229
  const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
16664
- const respectTimezone = broadcast.respectRecipientTimezone === true;
16665
- const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
16666
- for (; ; ) {
16667
- await ctx.collections.broadcasts.updateOne(
16668
- { _id: broadcast._id },
16669
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16670
- );
16671
- const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
16672
- if (page.contacts.length === 0) break;
16673
- const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
16674
- if (eligible.length > 0) {
17230
+ const respectTimezone = b.respectRecipientTimezone === true;
17231
+ const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
17232
+ const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
17233
+ let capReached = false;
17234
+ try {
17235
+ for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
17236
+ if (!await holdsLease()) return;
17237
+ const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
17238
+ if (bucket?.status === "tripped") {
17239
+ await pauseBroadcast(ctx, b._id, {
17240
+ code: "circuit_breaker",
17241
+ message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
17242
+ at: /* @__PURE__ */ new Date(),
17243
+ details: { bucket: bucket._id }
17244
+ });
17245
+ return;
17246
+ }
17247
+ const seen = await alreadyDispatched(ctx, b._id, eligible);
17248
+ let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
17249
+ if (fresh.length === 0) continue;
17250
+ if (cap !== null) {
17251
+ const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
17252
+ if (room <= 0) {
17253
+ capReached = true;
17254
+ break;
17255
+ }
17256
+ if (fresh.length > room) {
17257
+ fresh = fresh.slice(0, room);
17258
+ capReached = true;
17259
+ }
17260
+ }
16675
17261
  while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
16676
- await ctx.collections.broadcasts.updateOne(
16677
- { _id: broadcast._id },
16678
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16679
- );
17262
+ if (!await holdsLease()) return;
16680
17263
  await sleep(2e3);
16681
17264
  }
16682
- const sendDocs = await Promise.all(
16683
- eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
16684
- );
16685
17265
  const inserted = [];
16686
- for (const { doc, delayMs } of sendDocs) {
16687
- if (!doc) continue;
17266
+ for (const contact of fresh) {
17267
+ const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
16688
17268
  try {
16689
17269
  await ctx.collections.sends.insertOne(doc);
16690
17270
  inserted.push({ sendId: doc._id, delayMs });
@@ -16692,115 +17272,51 @@ async function dispatchBroadcast(broadcast, ctx) {
16692
17272
  if (err?.code !== 11e3) throw err;
16693
17273
  }
16694
17274
  }
16695
- if (inserted.length > 0) {
16696
- await Promise.all(
16697
- inserted.map(
16698
- ({ sendId, delayMs }) => ctx.queues.send.add(
16699
- "send",
16700
- { sendId: String(sendId) },
16701
- {
16702
- attempts: ctx.config.sendRetryAttempts,
16703
- backoff: { type: "exponential", delay: 6e4 },
16704
- ...delayMs > 0 ? { delay: delayMs } : {}
16705
- }
16706
- )
17275
+ await Promise.all(
17276
+ inserted.map(
17277
+ ({ sendId, delayMs }) => ctx.queues.send.add(
17278
+ "send",
17279
+ { sendId: String(sendId) },
17280
+ {
17281
+ attempts: ctx.config.sendRetryAttempts,
17282
+ backoff: { type: "exponential", delay: 6e4 },
17283
+ ...delayMs > 0 ? { delay: delayMs } : {}
17284
+ }
16707
17285
  )
16708
- );
16709
- }
16710
- total += inserted.length;
16711
- }
16712
- if (!page.nextCursor) break;
16713
- cursor = page.nextCursor;
16714
- }
16715
- await ctx.collections.broadcasts.updateOne(
16716
- { _id: broadcast._id },
16717
- {
16718
- $set: {
16719
- status: "sent",
16720
- completedAt: /* @__PURE__ */ new Date(),
16721
- recipientCount: total,
16722
- updatedAt: /* @__PURE__ */ new Date()
16723
- }
16724
- }
16725
- );
16726
- }
16727
- function toAdapterFilter(seg) {
16728
- const out = {};
16729
- for (const f of seg.filters) {
16730
- switch (f.kind) {
16731
- case "hasTag":
16732
- out.hasTag = f.tag;
16733
- break;
16734
- case "fieldEquals":
16735
- out.fieldEquals = { field: f.field, value: f.value };
16736
- break;
16737
- case "fieldIn":
16738
- out.fieldIn = { field: f.field, values: f.values };
16739
- break;
16740
- case "fieldExists":
16741
- out.fieldExists = f.field;
16742
- break;
16743
- }
16744
- }
16745
- return out;
16746
- }
16747
- function isMailerSide(f) {
16748
- return f.kind === "subscriptionStatus" || f.kind === "firedEvent" || f.kind === "notFiredEvent" || f.kind === "subscribedAfter" || f.kind === "subscribedBefore" || f.kind === "opened" || f.kind === "notOpened" || f.kind === "notHasTag" || f.kind === "any" || f.kind === "not";
16749
- }
16750
- async function applyPostFilters(contacts, filters, ctx) {
16751
- if (filters.length === 0) return contacts;
16752
- const externalIds = contacts.map((c) => c.externalId);
16753
- const cache = {};
16754
- for (const f of filters) {
16755
- if (f.kind === "subscriptionStatus") {
16756
- const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
16757
- cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
16758
- }
16759
- if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
16760
- const query = { externalId: { $in: externalIds }, name: f.eventName };
16761
- if (f.withinDays) {
16762
- query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
16763
- }
16764
- const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
16765
- cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
17286
+ )
17287
+ );
17288
+ if (capReached) break;
16766
17289
  }
16767
- }
16768
- return contacts.filter((c) => filters.every((f) => filterMatches(c, f, cache)));
16769
- }
16770
- function filterMatches(c, f, cache) {
16771
- switch (f.kind) {
16772
- case "subscriptionStatus":
16773
- return cache[`sub:${f.equals}`]?.has(c.externalId) ?? false;
16774
- case "firedEvent":
16775
- return cache[`evt:${f.eventName}`]?.has(c.externalId) ?? false;
16776
- case "notFiredEvent":
16777
- return !cache[`evt:${f.eventName}`]?.has(c.externalId);
16778
- case "notHasTag":
16779
- return !c.tags.includes(f.tag);
16780
- case "opened":
16781
- case "notOpened":
16782
- return true;
16783
- case "subscribedAfter":
16784
- case "subscribedBefore":
16785
- return true;
16786
- // V2
16787
- case "any":
16788
- return f.filters.some((sub) => filterMatches(c, sub, cache));
16789
- case "not":
16790
- return !filterMatches(c, f.filter, cache);
16791
- default:
16792
- return true;
17290
+ const now = /* @__PURE__ */ new Date();
17291
+ const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
17292
+ const set2 = capReached ? {
17293
+ status: "paused",
17294
+ pausedAt: now,
17295
+ pauseReason: {
17296
+ code: "cap_reached",
17297
+ message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
17298
+ at: now,
17299
+ details: { recipientCap: cap, sendsSoFar: total }
17300
+ }
17301
+ } : { status: "sent", completedAt: now };
17302
+ await ctx.collections.broadcasts.updateOne(
17303
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
17304
+ { $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
17305
+ );
17306
+ } finally {
17307
+ await ctx.collections.broadcasts.updateOne(
17308
+ { _id: b._id, dispatchLeaseId: leaseId },
17309
+ { $set: { dispatchLeaseId: null } }
17310
+ );
16793
17311
  }
16794
17312
  }
16795
- async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
16796
- const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
16797
- if (supp.suppressed) return { doc: null, delayMs: 0 };
17313
+ function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
16798
17314
  const sendId = new ObjectId();
16799
- const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
16800
- let delayMs = Math.max(0, scheduledMs - Date.now());
17315
+ const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
17316
+ const now = Date.now();
17317
+ let delayMs = Math.max(0, scheduledMs - now);
16801
17318
  if (respectTimezone && contact.timezone) {
16802
- const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
16803
- delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
17319
+ delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
16804
17320
  }
16805
17321
  const doc = {
16806
17322
  _id: sendId,
@@ -16835,16 +17351,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
16835
17351
  queuedAt: /* @__PURE__ */ new Date(),
16836
17352
  updatedAt: /* @__PURE__ */ new Date(),
16837
17353
  sentAt: null,
16838
- deliveredAt: null
17354
+ deliveredAt: null,
17355
+ notBefore: new Date(Date.now() + delayMs)
16839
17356
  };
16840
17357
  return { doc, delayMs };
16841
17358
  }
17359
+ var DAY_MS = 24 * 60 * 60 * 1e3;
17360
+ var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
17361
+ function recipientSlotMs(scheduledMs, timezone, nowMs) {
17362
+ let anchor = scheduledMs;
17363
+ let target = anchor + perRecipientOffsetMs(anchor, timezone);
17364
+ const earliest = nowMs - TZ_SLOT_GRACE_MS;
17365
+ if (target < earliest) {
17366
+ anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
17367
+ target = anchor + perRecipientOffsetMs(anchor, timezone);
17368
+ if (target < earliest) target += DAY_MS;
17369
+ }
17370
+ return target;
17371
+ }
16842
17372
  function perRecipientOffsetMs(scheduledMs, timezone) {
16843
- const DAY_MS = 24 * 60 * 60 * 1e3;
16844
17373
  try {
16845
17374
  const scheduled = new Date(scheduledMs);
16846
- const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
16847
- const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hour12: false });
17375
+ const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
17376
+ const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
16848
17377
  const parse = (s) => {
16849
17378
  const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
16850
17379
  if (!m) return 0;
@@ -16965,9 +17494,9 @@ async function runDnsblChecks(ctx, opts = {}) {
16965
17494
  let listedCount = 0;
16966
17495
  async function processPair(p) {
16967
17496
  const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
16968
- const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
16969
- if (lookup.transient) return;
16970
- if (lookup.result === "listed") listedCount++;
17497
+ const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
17498
+ if (lookup2.transient) return;
17499
+ if (lookup2.result === "listed") listedCount++;
16971
17500
  await ctx.collections.dnsblChecks.updateOne(
16972
17501
  { target: p.target, list: p.list.host },
16973
17502
  {
@@ -16976,9 +17505,9 @@ async function runDnsblChecks(ctx, opts = {}) {
16976
17505
  targetKind: p.targetKind,
16977
17506
  list: p.list.host,
16978
17507
  listLabel: p.list.label,
16979
- result: lookup.result,
16980
- returnCodes: lookup.returnCodes,
16981
- errorMessage: lookup.errorMessage,
17508
+ result: lookup2.result,
17509
+ returnCodes: lookup2.returnCodes,
17510
+ errorMessage: lookup2.errorMessage,
16982
17511
  runAt: /* @__PURE__ */ new Date()
16983
17512
  }
16984
17513
  },
@@ -17819,6 +18348,9 @@ async function runTick(ctx) {
17819
18348
  await resumeStalledBroadcasts(ctx).catch((err) => {
17820
18349
  console.error("mailery: stalled-broadcast resume failed", err);
17821
18350
  });
18351
+ await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
18352
+ console.error("mailery: broadcast stop-rule sweep failed", err);
18353
+ });
17822
18354
  await evaluateHealth(ctx).catch((err) => {
17823
18355
  console.error("mailery: health evaluation failed", err);
17824
18356
  });
@@ -18523,6 +19055,25 @@ var Mailer = class _Mailer {
18523
19055
  };
18524
19056
 
18525
19057
  // src/testing/memory-adapter.ts
19058
+ function fieldPath(c, path2) {
19059
+ let cur = c.fields;
19060
+ for (const part of path2.split(".")) {
19061
+ if (cur === null || typeof cur !== "object") return void 0;
19062
+ cur = cur[part];
19063
+ }
19064
+ return cur;
19065
+ }
19066
+ function compareValues(a, b) {
19067
+ const an = a === null || a === void 0;
19068
+ const bn = b === null || b === void 0;
19069
+ if (an || bn) return an && bn ? 0 : an ? -1 : 1;
19070
+ const av = a instanceof Date ? a.getTime() : a;
19071
+ const bv = b instanceof Date ? b.getTime() : b;
19072
+ if (typeof av === "number" && typeof bv === "number") return av - bv;
19073
+ const as = String(av);
19074
+ const bs = String(bv);
19075
+ return as < bs ? -1 : as > bs ? 1 : 0;
19076
+ }
18526
19077
  var MemoryContactAdapter = class {
18527
19078
  byId = /* @__PURE__ */ new Map();
18528
19079
  constructor(initial = []) {
@@ -18557,8 +19108,29 @@ var MemoryContactAdapter = class {
18557
19108
  }
18558
19109
  return out;
18559
19110
  }
19111
+ /** `query` honours `opts.sort` over `contact.fields` (dotted paths), like MongoContactAdapter over documents. */
19112
+ supportsSort = true;
18560
19113
  async query(filter, opts) {
18561
- const all = Array.from(this.byId.values()).filter((c) => matches(c, filter)).sort((a, b) => a.externalId < b.externalId ? -1 : 1);
19114
+ if (opts.sort) {
19115
+ const { field, direction } = opts.sort;
19116
+ const dir = direction === "desc" ? -1 : 1;
19117
+ const sorted = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => {
19118
+ const byValue = compareValues(fieldPath(a, field), fieldPath(b, field)) * dir;
19119
+ if (byValue !== 0) return byValue;
19120
+ return a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0;
19121
+ });
19122
+ let start = 0;
19123
+ if (opts.cursor) {
19124
+ const at = sorted.findIndex((c) => c.externalId === opts.cursor);
19125
+ start = at === -1 ? sorted.length : at + 1;
19126
+ }
19127
+ const page = sorted.slice(start, start + opts.limit);
19128
+ return {
19129
+ contacts: page,
19130
+ nextCursor: start + opts.limit < sorted.length ? page[page.length - 1]?.externalId : void 0
19131
+ };
19132
+ }
19133
+ const all = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => a.externalId < b.externalId ? -1 : 1);
18562
19134
  const startIndex = opts.cursor ? all.findIndex((c) => c.externalId > opts.cursor) : 0;
18563
19135
  const slice = all.slice(startIndex === -1 ? all.length : startIndex, (startIndex === -1 ? all.length : startIndex) + opts.limit);
18564
19136
  const hasMore = startIndex !== -1 && startIndex + opts.limit < all.length;
@@ -18569,15 +19141,15 @@ var MemoryContactAdapter = class {
18569
19141
  }
18570
19142
  async count(filter) {
18571
19143
  let n = 0;
18572
- for (const c of this.byId.values()) if (matches(c, filter)) n++;
19144
+ for (const c of this.byId.values()) if (matches2(c, filter)) n++;
18573
19145
  return n;
18574
19146
  }
18575
19147
  async addTags(externalId, tags) {
18576
19148
  const c = this.byId.get(externalId);
18577
19149
  if (!c) return;
18578
- const set = new Set(c.tags);
18579
- for (const t of tags) set.add(t);
18580
- c.tags = Array.from(set);
19150
+ const set2 = new Set(c.tags);
19151
+ for (const t of tags) set2.add(t);
19152
+ c.tags = Array.from(set2);
18581
19153
  }
18582
19154
  async removeTags(externalId, tags) {
18583
19155
  const c = this.byId.get(externalId);
@@ -18586,7 +19158,7 @@ var MemoryContactAdapter = class {
18586
19158
  c.tags = c.tags.filter((t) => !remove.has(t));
18587
19159
  }
18588
19160
  };
18589
- function matches(c, f) {
19161
+ function matches2(c, f) {
18590
19162
  if (f.emailIn && !f.emailIn.map((e) => e.toLowerCase()).includes(c.email)) return false;
18591
19163
  if (f.externalIdIn && !f.externalIdIn.includes(c.externalId)) return false;
18592
19164
  if (f.fieldEquals && c.fields[f.fieldEquals.field] !== f.fieldEquals.value) return false;