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.cjs CHANGED
@@ -134,6 +134,42 @@ __export(mongo_exports, {
134
134
  function canBeObjectId(s) {
135
135
  return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
136
136
  }
137
+ function getPath(doc, path2) {
138
+ let cur = doc;
139
+ for (const part of path2.split(".")) {
140
+ if (cur === null || typeof cur !== "object") return void 0;
141
+ cur = cur[part];
142
+ }
143
+ return cur;
144
+ }
145
+ function isObjectIdLike(v) {
146
+ return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
147
+ }
148
+ function packScalar(v) {
149
+ if (v instanceof Date) return { d: v.toISOString() };
150
+ if (isObjectIdLike(v)) return { o: v.toHexString() };
151
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
152
+ return null;
153
+ }
154
+ function unpackScalar(v) {
155
+ if (v && typeof v === "object") {
156
+ if ("d" in v) return new Date(v.d);
157
+ if ("o" in v) return new mongodb.ObjectId(v.o);
158
+ }
159
+ return v;
160
+ }
161
+ function encodeSortCursor(value, id) {
162
+ return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
163
+ }
164
+ function decodeSortCursor(cursor) {
165
+ try {
166
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
167
+ if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
168
+ return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
137
173
  var MongoContactAdapter;
138
174
  var init_mongo = __esm({
139
175
  "src/server/adapters/mongo.ts"() {
@@ -183,7 +219,10 @@ var init_mongo = __esm({
183
219
  }
184
220
  return out;
185
221
  }
222
+ /** `query` honours `opts.sort` — see `querySorted`. */
223
+ supportsSort = true;
186
224
  async query(filter, opts) {
225
+ if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
187
226
  const query = this.translateFilterFn(filter);
188
227
  const limit = Math.min(opts.limit, this.batchSize);
189
228
  if (opts.cursor) {
@@ -202,6 +241,52 @@ var init_mongo = __esm({
202
241
  const query = this.translateFilterFn(filter);
203
242
  return await this.col.countDocuments(query);
204
243
  }
244
+ /**
245
+ * Keyset pagination in (sort.field, idField) order. The cursor encodes the
246
+ * last row's sort value and id, and the next page is every row strictly
247
+ * after that position. Mongo sorts a null or missing value lowest, so
248
+ * `desc` puts contacts without the field last and `asc` puts them first;
249
+ * the position predicate follows the same rule, so they are neither
250
+ * skipped nor repeated.
251
+ *
252
+ * The field should hold one BSON type (or be absent): range operators only
253
+ * compare within a type. A value that moves while a pass is paging (a
254
+ * contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
255
+ * at all in that pass; broadcast dispatch tolerates both (the per-recipient
256
+ * dedupe key stops a second send, and a later pass picks up a skipped one).
257
+ * Index `{ <field>: -1, <idField>: 1 }` on a large collection.
258
+ */
259
+ async querySorted(filter, limitIn, cursor, sort) {
260
+ const base = this.translateFilterFn(filter);
261
+ const limit = Math.min(limitIn, this.batchSize);
262
+ const dir = sort.direction === "desc" ? -1 : 1;
263
+ let query = base;
264
+ if (cursor) {
265
+ const pos = decodeSortCursor(cursor);
266
+ if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
267
+ query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
268
+ }
269
+ const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
270
+ const hasMore = docs.length > limit;
271
+ const slice = hasMore ? docs.slice(0, limit) : docs;
272
+ const last = slice[slice.length - 1];
273
+ return {
274
+ contacts: slice.map((d) => this.toContactFn(d)),
275
+ nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
276
+ };
277
+ }
278
+ afterPosition(field, dir, value, id) {
279
+ const idF = this.idField;
280
+ const isNull = value === null || value === void 0;
281
+ if (dir === -1) {
282
+ if (isNull) return { [field]: null, [idF]: { $gt: id } };
283
+ return {
284
+ $or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
285
+ };
286
+ }
287
+ if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
288
+ return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
289
+ }
205
290
  addTags;
206
291
  removeTags;
207
292
  // -------------------------------------------------------------------------
@@ -1941,9 +2026,9 @@ https://typegoose.github.io/mongodb-memory-server/docs/guides/migration/migrate7
1941
2026
  exports.AssertionFallbackError = AssertionFallbackError;
1942
2027
  var ReplsetCountLowError = class extends Error {
1943
2028
  count;
1944
- constructor(count) {
1945
- super(`ReplSet Count needs to be 1 or higher! (specified count: "${count}")`);
1946
- this.count = count;
2029
+ constructor(count2) {
2030
+ super(`ReplSet Count needs to be 1 or higher! (specified count: "${count2}")`);
2031
+ this.count = count2;
1947
2032
  }
1948
2033
  };
1949
2034
  exports.ReplsetCountLowError = ReplsetCountLowError;
@@ -3275,20 +3360,20 @@ var require_range = __commonJS({
3275
3360
  }
3276
3361
  return `${from} ${to}`.trim();
3277
3362
  };
3278
- var testSet = (set, version, options2) => {
3279
- for (let i = 0; i < set.length; i++) {
3280
- if (!set[i].test(version)) {
3363
+ var testSet = (set2, version, options2) => {
3364
+ for (let i = 0; i < set2.length; i++) {
3365
+ if (!set2[i].test(version)) {
3281
3366
  return false;
3282
3367
  }
3283
3368
  }
3284
3369
  if (version.prerelease.length && !options2.includePrerelease) {
3285
- for (let i = 0; i < set.length; i++) {
3286
- debug(set[i].semver);
3287
- if (set[i].semver === Comparator.ANY) {
3370
+ for (let i = 0; i < set2.length; i++) {
3371
+ debug(set2[i].semver);
3372
+ if (set2[i].semver === Comparator.ANY) {
3288
3373
  continue;
3289
3374
  }
3290
- if (set[i].semver.prerelease.length > 0) {
3291
- const allowed = set[i].semver;
3375
+ if (set2[i].semver.prerelease.length > 0) {
3376
+ const allowed = set2[i].semver;
3292
3377
  if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
3293
3378
  return true;
3294
3379
  }
@@ -3672,7 +3757,7 @@ var require_simplify = __commonJS({
3672
3757
  var satisfies = require_satisfies();
3673
3758
  var compare = require_compare();
3674
3759
  module.exports = (versions, range, options2) => {
3675
- const set = [];
3760
+ const set2 = [];
3676
3761
  let first = null;
3677
3762
  let prev = null;
3678
3763
  const v = versions.sort((a, b) => compare(a, b, options2));
@@ -3685,17 +3770,17 @@ var require_simplify = __commonJS({
3685
3770
  }
3686
3771
  } else {
3687
3772
  if (prev) {
3688
- set.push([first, prev]);
3773
+ set2.push([first, prev]);
3689
3774
  }
3690
3775
  prev = null;
3691
3776
  first = null;
3692
3777
  }
3693
3778
  }
3694
3779
  if (first) {
3695
- set.push([first, null]);
3780
+ set2.push([first, null]);
3696
3781
  }
3697
3782
  const ranges = [];
3698
- for (const [min, max] of set) {
3783
+ for (const [min, max] of set2) {
3699
3784
  if (min === max) {
3700
3785
  ranges.push(min);
3701
3786
  } else if (!max && min === v[0]) {
@@ -10672,18 +10757,18 @@ var require_semver3 = __commonJS({
10672
10757
  range = range.replace(safeRe[t.CARETTRIM], caretTrimReplace);
10673
10758
  range = range.split(/\s+/).join(" ");
10674
10759
  var compRe = loose ? safeRe[t.COMPARATORLOOSE] : safeRe[t.COMPARATOR];
10675
- var set = range.split(" ").map(function(comp) {
10760
+ var set2 = range.split(" ").map(function(comp) {
10676
10761
  return parseComparator(comp, this.options);
10677
10762
  }, this).join(" ").split(/\s+/);
10678
10763
  if (this.options.loose) {
10679
- set = set.filter(function(comp) {
10764
+ set2 = set2.filter(function(comp) {
10680
10765
  return !!comp.match(compRe);
10681
10766
  });
10682
10767
  }
10683
- set = set.map(function(comp) {
10768
+ set2 = set2.map(function(comp) {
10684
10769
  return new Comparator(comp, this.options);
10685
10770
  }, this);
10686
- return set;
10771
+ return set2;
10687
10772
  };
10688
10773
  Range.prototype.intersects = function(range, options2) {
10689
10774
  if (!(range instanceof Range)) {
@@ -10911,20 +10996,20 @@ var require_semver3 = __commonJS({
10911
10996
  }
10912
10997
  return false;
10913
10998
  };
10914
- function testSet(set, version, options2) {
10915
- for (var i2 = 0; i2 < set.length; i2++) {
10916
- if (!set[i2].test(version)) {
10999
+ function testSet(set2, version, options2) {
11000
+ for (var i2 = 0; i2 < set2.length; i2++) {
11001
+ if (!set2[i2].test(version)) {
10917
11002
  return false;
10918
11003
  }
10919
11004
  }
10920
11005
  if (version.prerelease.length && !options2.includePrerelease) {
10921
- for (i2 = 0; i2 < set.length; i2++) {
10922
- debug(set[i2].semver);
10923
- if (set[i2].semver === ANY) {
11006
+ for (i2 = 0; i2 < set2.length; i2++) {
11007
+ debug(set2[i2].semver);
11008
+ if (set2[i2].semver === ANY) {
10924
11009
  continue;
10925
11010
  }
10926
- if (set[i2].semver.prerelease.length > 0) {
10927
- var allowed = set[i2].semver;
11011
+ if (set2[i2].semver.prerelease.length > 0) {
11012
+ var allowed = set2[i2].semver;
10928
11013
  if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
10929
11014
  return true;
10930
11015
  }
@@ -14343,6 +14428,8 @@ var sendOneOffInputSchema = zod.z.object({
14343
14428
  providerOverride: zod.z.string().optional(),
14344
14429
  dedupeKey: zod.z.string().min(1).max(512)
14345
14430
  });
14431
+ zod.z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
14432
+ zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()]);
14346
14433
  var flowStepSchema = zod.z.lazy(
14347
14434
  () => zod.z.discriminatedUnion("type", [
14348
14435
  zod.z.object({
@@ -14511,6 +14598,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
14511
14598
  windowMinutes: 60,
14512
14599
  minSendsBeforeEval: 100
14513
14600
  };
14601
+ var BROADCAST_STOP_RULE_DEFAULTS = {
14602
+ enabled: true,
14603
+ hardBounceRatePct: 2,
14604
+ complaintRatePct: 0.1,
14605
+ unsubscribeRatePct: 1,
14606
+ minSample: 100
14607
+ };
14514
14608
  function resolveConfig(c) {
14515
14609
  return {
14516
14610
  ...c,
@@ -14538,7 +14632,8 @@ function resolveConfig(c) {
14538
14632
  storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
14539
14633
  requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
14540
14634
  trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
14541
- circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
14635
+ circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
14636
+ broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
14542
14637
  };
14543
14638
  }
14544
14639
 
@@ -14756,11 +14851,13 @@ function resolveProvider(providers, name) {
14756
14851
  function registeredProviderNames(providers) {
14757
14852
  return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
14758
14853
  }
14854
+ var SEND_ID_RE = /^[a-f0-9]{24}$/i;
14759
14855
  function signUnsubscribeToken(payload, secret) {
14760
14856
  const body = JSON.stringify({
14761
14857
  e: payload.email.toLowerCase(),
14762
14858
  s: payload.scope,
14763
- x: payload.expiresAt.getTime()
14859
+ x: payload.expiresAt.getTime(),
14860
+ ...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
14764
14861
  });
14765
14862
  const bodyB64 = b64url(Buffer.from(body, "utf8"));
14766
14863
  const hmac = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
@@ -15464,6 +15561,179 @@ async function getBucketStatus(ctx, fromEmail, kind) {
15464
15561
  const id = healthBucketId(domain, kind);
15465
15562
  return ctx.collections.health.findOne({ _id: id });
15466
15563
  }
15564
+ function emptyBroadcastStats() {
15565
+ return withRates({
15566
+ total: 0,
15567
+ accepted: 0,
15568
+ delivered: 0,
15569
+ bounced: 0,
15570
+ hardBounced: 0,
15571
+ softBounced: 0,
15572
+ complained: 0,
15573
+ unsubscribed: 0,
15574
+ opened: 0,
15575
+ clicked: 0,
15576
+ outcomes: 0
15577
+ });
15578
+ }
15579
+ var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
15580
+ function withRates(c) {
15581
+ return {
15582
+ ...c,
15583
+ rates: {
15584
+ deliveryRatePct: pct(c.delivered, c.accepted),
15585
+ bounceRatePct: pct(c.bounced, c.outcomes),
15586
+ hardBounceRatePct: pct(c.hardBounced, c.outcomes),
15587
+ complaintRatePct: pct(c.complained, c.outcomes),
15588
+ unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
15589
+ openRatePct: pct(c.opened, c.delivered),
15590
+ clickRatePct: pct(c.clicked, c.delivered)
15591
+ }
15592
+ };
15593
+ }
15594
+ var set = (field) => ({ $ifNull: [field, false] });
15595
+ var DELIVERED = {
15596
+ $or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
15597
+ };
15598
+ var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
15599
+ var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
15600
+ async function aggregateBroadcastStats(collections, broadcastId) {
15601
+ const rows = await collections.sends.aggregate([
15602
+ { $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
15603
+ {
15604
+ $group: {
15605
+ _id: "$broadcastId",
15606
+ total: { $sum: 1 },
15607
+ accepted: count(set("$sentAt")),
15608
+ delivered: count(DELIVERED),
15609
+ bounced: count(BOUNCED),
15610
+ hardBounced: count({ $eq: ["$bounceType", "hard"] }),
15611
+ softBounced: count({ $eq: ["$bounceType", "soft"] }),
15612
+ complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
15613
+ unsubscribed: count(set("$unsubscribedAt")),
15614
+ opened: count(set("$openedAt")),
15615
+ clicked: count(set("$firstClickAt")),
15616
+ outcomes: count({ $or: [DELIVERED, BOUNCED] })
15617
+ }
15618
+ }
15619
+ ]).toArray();
15620
+ const out = /* @__PURE__ */ new Map();
15621
+ for (const { _id, ...c } of rows) {
15622
+ if (_id) out.set(String(_id), withRates(c));
15623
+ }
15624
+ return out;
15625
+ }
15626
+ async function broadcastStatsFor(collections, broadcastId) {
15627
+ return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
15628
+ }
15629
+ function effectiveStopRules(config, b) {
15630
+ return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
15631
+ }
15632
+ var RULE_COUNTS = [
15633
+ ["complaintRatePct", "complained"],
15634
+ ["hardBounceRatePct", "hardBounced"],
15635
+ ["unsubscribeRatePct", "unsubscribed"]
15636
+ ];
15637
+ function evaluateStopRules(stats, rules) {
15638
+ const sample = stats.outcomes;
15639
+ const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
15640
+ const breaches = [];
15641
+ if (evaluated) {
15642
+ for (const [rule, key] of RULE_COUNTS) {
15643
+ const n = stats[key];
15644
+ const exactPct = n / sample * 100;
15645
+ if (exactPct > rules[rule]) {
15646
+ breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
15647
+ }
15648
+ }
15649
+ }
15650
+ return { rules, sample, evaluated, breaches };
15651
+ }
15652
+ var RULE_LABEL = {
15653
+ complaintRatePct: "complaint rate",
15654
+ hardBounceRatePct: "hard bounce rate",
15655
+ unsubscribeRatePct: "unsubscribe rate"
15656
+ };
15657
+ var PAUSE_PRECEDENCE = {
15658
+ manual: 1,
15659
+ cap_reached: 1,
15660
+ stop_rule: 2,
15661
+ circuit_breaker: 2
15662
+ };
15663
+ async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
15664
+ const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
15665
+ (k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
15666
+ );
15667
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
15668
+ {
15669
+ _id: broadcastId,
15670
+ $or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
15671
+ },
15672
+ { $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
15673
+ { returnDocument: "after" }
15674
+ );
15675
+ if (!b) return { paused: false, heldSends: 0 };
15676
+ const held = await holdQueuedSends(ctx, broadcastId, reason.code);
15677
+ if (ctx.audit) {
15678
+ await ctx.audit({
15679
+ actor,
15680
+ action: "broadcast.pause",
15681
+ resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
15682
+ diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
15683
+ }).catch(() => {
15684
+ });
15685
+ }
15686
+ if (ctx.config.onBroadcastPaused) {
15687
+ try {
15688
+ await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
15689
+ } catch {
15690
+ }
15691
+ }
15692
+ return { paused: true, heldSends: held };
15693
+ }
15694
+ async function holdQueuedSends(ctx, broadcastId, code) {
15695
+ const res = await ctx.collections.sends.updateMany(
15696
+ { broadcastId, status: "queued" },
15697
+ { $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
15698
+ );
15699
+ return res.modifiedCount;
15700
+ }
15701
+ async function evaluateBroadcastStopRules(ctx, broadcastId) {
15702
+ const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
15703
+ if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
15704
+ if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
15705
+ const stats = await broadcastStatsFor(ctx.collections, broadcastId);
15706
+ const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
15707
+ if (evaluation.breaches.length === 0) return evaluation;
15708
+ const now = /* @__PURE__ */ new Date();
15709
+ if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
15710
+ await ctx.collections.broadcasts.updateOne(
15711
+ { _id: broadcastId, stopRuleBreach: { $in: [null] } },
15712
+ { $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
15713
+ );
15714
+ return evaluation;
15715
+ }
15716
+ const first = evaluation.breaches[0];
15717
+ await pauseBroadcast(ctx, broadcastId, {
15718
+ code: "stop_rule",
15719
+ message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
15720
+ at: now,
15721
+ details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
15722
+ });
15723
+ return evaluation;
15724
+ }
15725
+ var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
15726
+ async function evaluateActiveBroadcastStopRules(ctx) {
15727
+ const live = await ctx.collections.broadcasts.find(
15728
+ { status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
15729
+ { projection: { _id: 1 } }
15730
+ ).limit(200).toArray();
15731
+ for (const { _id } of live) {
15732
+ await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
15733
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
15734
+ });
15735
+ }
15736
+ }
15467
15737
 
15468
15738
  // src/server/runner/webhook.ts
15469
15739
  function dimsFromSend(send) {
@@ -15614,6 +15884,11 @@ async function applyWebhookEvent(event, ctx) {
15614
15884
  );
15615
15885
  break;
15616
15886
  }
15887
+ if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
15888
+ await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
15889
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
15890
+ });
15891
+ }
15617
15892
  }
15618
15893
  async function suppressOnce(ctx, email, reason, scope) {
15619
15894
  const normalized = email.toLowerCase();
@@ -16011,11 +16286,30 @@ async function isSuppressed(collections, email, kind) {
16011
16286
  if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
16012
16287
  const hashed = await collections.suppressions.findOne({
16013
16288
  emailHash: sha256Hex(normalized),
16014
- scope: { $in: allowed }
16289
+ scope: { $in: allowed },
16290
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
16015
16291
  });
16016
16292
  if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
16017
16293
  return { suppressed: false };
16018
16294
  }
16295
+ async function suppressedEmails(collections, emails, kind) {
16296
+ const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
16297
+ if (normalized.length === 0) return /* @__PURE__ */ new Set();
16298
+ const allowed = SCOPES_BY_KIND[kind];
16299
+ const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
16300
+ const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
16301
+ const [plain, hashed] = await Promise.all([
16302
+ collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
16303
+ collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
16304
+ ]);
16305
+ const out = /* @__PURE__ */ new Set();
16306
+ for (const e of plain) if (typeof e === "string") out.add(e);
16307
+ for (const h of hashed) {
16308
+ const e = byHash.get(String(h));
16309
+ if (e) out.add(e);
16310
+ }
16311
+ return out;
16312
+ }
16019
16313
 
16020
16314
  // src/server/runner/send.ts
16021
16315
  async function handleSend(run, step2, contact, flow, ctx) {
@@ -16092,6 +16386,27 @@ async function dispatchSend(sendId, ctx) {
16092
16386
  await markFailed(send._id, "template_missing", ctx);
16093
16387
  return;
16094
16388
  }
16389
+ if (send.broadcastId) {
16390
+ const broadcast = await ctx.collections.broadcasts.findOne(
16391
+ { _id: send.broadcastId },
16392
+ { projection: { status: 1, pauseReason: 1 } }
16393
+ );
16394
+ const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
16395
+ if (holding || broadcast?.status === "cancelled") {
16396
+ const held = holding;
16397
+ await ctx.collections.sends.updateOne(
16398
+ { _id: send._id },
16399
+ {
16400
+ $set: {
16401
+ status: held ? "held" : "cancelled",
16402
+ errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
16403
+ updatedAt: /* @__PURE__ */ new Date()
16404
+ }
16405
+ }
16406
+ );
16407
+ return;
16408
+ }
16409
+ }
16095
16410
  const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
16096
16411
  if (supp.suppressed) {
16097
16412
  await ctx.collections.sends.updateOne(
@@ -16102,6 +16417,19 @@ async function dispatchSend(sendId, ctx) {
16102
16417
  }
16103
16418
  if (send.kind === "marketing") {
16104
16419
  const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
16420
+ if (bucket?.status === "tripped" && send.broadcastId) {
16421
+ await ctx.collections.sends.updateOne(
16422
+ { _id: send._id },
16423
+ { $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
16424
+ );
16425
+ await pauseBroadcast(ctx, send.broadcastId, {
16426
+ code: "circuit_breaker",
16427
+ message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
16428
+ at: /* @__PURE__ */ new Date(),
16429
+ details: { bucket: bucket._id }
16430
+ });
16431
+ return;
16432
+ }
16105
16433
  if (bucket?.status === "tripped") {
16106
16434
  await ctx.collections.sends.updateOne(
16107
16435
  { _id: send._id },
@@ -16134,7 +16462,7 @@ async function dispatchSend(sendId, ctx) {
16134
16462
  eventName: run?.triggerEvent?.name,
16135
16463
  eventProperties: run?.triggerEvent?.properties
16136
16464
  });
16137
- renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
16465
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
16138
16466
  rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
16139
16467
  } catch (err) {
16140
16468
  await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
@@ -16225,11 +16553,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
16225
16553
  }
16226
16554
  return ctx.config.defaultProvider;
16227
16555
  }
16228
- function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
16556
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
16229
16557
  const scope = "marketing";
16230
16558
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
16231
16559
  const token = signUnsubscribeToken(
16232
- { email: contact.email, scope, expiresAt },
16560
+ { email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
16233
16561
  ctx.config.unsubscribeSecret
16234
16562
  );
16235
16563
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
@@ -16605,6 +16933,184 @@ async function sweepStrandedFlowRuns(ctx) {
16605
16933
  }
16606
16934
  }
16607
16935
  }
16936
+
16937
+ // src/server/runner/segment.ts
16938
+ function planSegment(seg) {
16939
+ const hostFilter = {};
16940
+ const postFilters = [];
16941
+ const hostFields = /* @__PURE__ */ new Set();
16942
+ const takeField = (field) => {
16943
+ if (hostFields.has(field)) return false;
16944
+ hostFields.add(field);
16945
+ return true;
16946
+ };
16947
+ for (const f of seg.filters ?? []) {
16948
+ switch (f.kind) {
16949
+ case "hasTag":
16950
+ if (hostFilter.hasTag === void 0) {
16951
+ hostFilter.hasTag = f.tag;
16952
+ continue;
16953
+ }
16954
+ break;
16955
+ case "fieldEquals":
16956
+ if (!hostFilter.fieldEquals && takeField(f.field)) {
16957
+ hostFilter.fieldEquals = { field: f.field, value: f.value };
16958
+ continue;
16959
+ }
16960
+ break;
16961
+ case "fieldIn":
16962
+ if (!hostFilter.fieldIn && takeField(f.field)) {
16963
+ hostFilter.fieldIn = { field: f.field, values: f.values };
16964
+ continue;
16965
+ }
16966
+ break;
16967
+ case "fieldExists":
16968
+ if (!hostFilter.fieldExists && takeField(f.field)) {
16969
+ hostFilter.fieldExists = f.field;
16970
+ continue;
16971
+ }
16972
+ break;
16973
+ }
16974
+ postFilters.push(f);
16975
+ }
16976
+ return { hostFilter, postFilters };
16977
+ }
16978
+ async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
16979
+ if (filters.length === 0 || contacts.length === 0) return contacts;
16980
+ const externalIds = contacts.map((c) => c.externalId);
16981
+ const leaves = /* @__PURE__ */ new Map();
16982
+ collectLookups(filters, leaves);
16983
+ const cache = /* @__PURE__ */ new Map();
16984
+ await Promise.all(
16985
+ [...leaves].map(async ([key, f]) => {
16986
+ cache.set(key, await lookup(f, externalIds, ctx, now));
16987
+ })
16988
+ );
16989
+ return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
16990
+ }
16991
+ function lookupKey(f) {
16992
+ switch (f.kind) {
16993
+ case "subscriptionStatus":
16994
+ return `sub:${f.equals}`;
16995
+ case "firedEvent":
16996
+ case "notFiredEvent":
16997
+ return `evt:${f.withinDays ?? ""}:${f.eventName}`;
16998
+ case "opened":
16999
+ case "notOpened":
17000
+ return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
17001
+ case "subscribedAfter":
17002
+ return `subAfter:${toDate(f.date).getTime()}`;
17003
+ case "subscribedBefore":
17004
+ return `subBefore:${toDate(f.date).getTime()}`;
17005
+ default:
17006
+ return null;
17007
+ }
17008
+ }
17009
+ function collectLookups(filters, out) {
17010
+ for (const f of filters) {
17011
+ if (f.kind === "any") collectLookups(f.filters, out);
17012
+ else if (f.kind === "not") collectLookups([f.filter], out);
17013
+ else {
17014
+ const key = lookupKey(f);
17015
+ if (key && !out.has(key)) out.set(key, f);
17016
+ }
17017
+ }
17018
+ }
17019
+ async function lookup(f, externalIds, ctx, now) {
17020
+ const c = ctx.collections;
17021
+ const inPage = { $in: externalIds };
17022
+ const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
17023
+ let ids;
17024
+ switch (f.kind) {
17025
+ case "subscriptionStatus":
17026
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
17027
+ break;
17028
+ case "firedEvent":
17029
+ case "notFiredEvent": {
17030
+ const cutoff = since(f.withinDays);
17031
+ ids = await c.events.distinct("externalId", {
17032
+ externalId: inPage,
17033
+ name: f.eventName,
17034
+ ...cutoff ? { occurredAt: { $gt: cutoff } } : {}
17035
+ });
17036
+ break;
17037
+ }
17038
+ case "opened":
17039
+ case "notOpened": {
17040
+ const cutoff = since(f.withinDays);
17041
+ ids = await c.sends.distinct("externalId", {
17042
+ externalId: inPage,
17043
+ openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
17044
+ ...f.templateSlug ? { templateSlug: f.templateSlug } : {}
17045
+ });
17046
+ break;
17047
+ }
17048
+ case "subscribedAfter":
17049
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
17050
+ break;
17051
+ case "subscribedBefore":
17052
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
17053
+ break;
17054
+ default:
17055
+ ids = [];
17056
+ }
17057
+ return new Set(ids.map(String));
17058
+ }
17059
+ function matches(c, f, cache) {
17060
+ switch (f.kind) {
17061
+ case "subscriptionStatus":
17062
+ case "firedEvent":
17063
+ case "opened":
17064
+ case "subscribedAfter":
17065
+ case "subscribedBefore":
17066
+ return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
17067
+ case "notFiredEvent":
17068
+ case "notOpened":
17069
+ return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
17070
+ case "hasTag":
17071
+ return c.tags.includes(f.tag);
17072
+ case "notHasTag":
17073
+ return !c.tags.includes(f.tag);
17074
+ case "fieldEquals":
17075
+ return valuesEqual(fieldValue(c, f.field), f.value);
17076
+ case "fieldIn": {
17077
+ const v = fieldValue(c, f.field);
17078
+ return f.values.some((x) => valuesEqual(v, x));
17079
+ }
17080
+ case "fieldExists":
17081
+ return fieldValue(c, f.field) !== void 0;
17082
+ case "any":
17083
+ return f.filters.some((sub) => matches(c, sub, cache));
17084
+ case "not":
17085
+ return !matches(c, f.filter, cache);
17086
+ default:
17087
+ throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
17088
+ }
17089
+ }
17090
+ function fieldValue(c, path2) {
17091
+ let cur = c.fields;
17092
+ for (const part of path2.split(".")) {
17093
+ if (cur === null || typeof cur !== "object") return void 0;
17094
+ cur = cur[part];
17095
+ }
17096
+ return cur;
17097
+ }
17098
+ function valuesEqual(a, b) {
17099
+ if (a instanceof Date || b instanceof Date) {
17100
+ const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
17101
+ const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
17102
+ return !Number.isNaN(ta) && ta === tb;
17103
+ }
17104
+ if (a && typeof a === "object" && typeof a.toHexString === "function") {
17105
+ return String(a) === String(b);
17106
+ }
17107
+ return a === b;
17108
+ }
17109
+ function toDate(d) {
17110
+ return d instanceof Date ? d : new Date(d);
17111
+ }
17112
+
17113
+ // src/server/runner/broadcasts.ts
16608
17114
  var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
16609
17115
  async function processScheduledBroadcasts(ctx) {
16610
17116
  const now = /* @__PURE__ */ new Date();
@@ -16612,7 +17118,7 @@ async function processScheduledBroadcasts(ctx) {
16612
17118
  for (const b of due) {
16613
17119
  const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
16614
17120
  { _id: b._id, status: "scheduled" },
16615
- { $set: { status: "sending", startedAt: now, updatedAt: now } },
17121
+ { $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
16616
17122
  { returnDocument: "after" }
16617
17123
  );
16618
17124
  if (!claimed) continue;
@@ -16620,8 +17126,14 @@ async function processScheduledBroadcasts(ctx) {
16620
17126
  }
16621
17127
  }
16622
17128
  async function startBroadcastDispatch(broadcast, ctx) {
17129
+ const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
17130
+ { _id: broadcast._id },
17131
+ { $inc: { dispatchGeneration: 1 } },
17132
+ { returnDocument: "after" }
17133
+ );
17134
+ const current = bumped ?? broadcast;
16623
17135
  if (ctx.config.queue.driver === "noop") {
16624
- await runBroadcastDispatch(broadcast, ctx);
17136
+ await runBroadcastDispatch(current, ctx);
16625
17137
  return;
16626
17138
  }
16627
17139
  await ctx.queues.advance.add(
@@ -16630,7 +17142,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
16630
17142
  {
16631
17143
  attempts: 3,
16632
17144
  backoff: { type: "exponential", delay: 6e4 },
16633
- jobId: `broadcast-dispatch:${broadcast._id}`
17145
+ jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
16634
17146
  }
16635
17147
  );
16636
17148
  }
@@ -16643,10 +17155,11 @@ async function resumeStalledBroadcasts(ctx) {
16643
17155
  const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
16644
17156
  const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
16645
17157
  for (const b of stalled) {
16646
- await ctx.collections.broadcasts.updateOne(
16647
- { _id: b._id },
16648
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
17158
+ const reset = await ctx.collections.broadcasts.updateOne(
17159
+ { _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
17160
+ { $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
16649
17161
  );
17162
+ if (reset.modifiedCount === 0) continue;
16650
17163
  await startBroadcastDispatch(b, ctx);
16651
17164
  }
16652
17165
  }
@@ -16656,50 +17169,117 @@ async function runBroadcastDispatch(broadcast, ctx) {
16656
17169
  } catch (err) {
16657
17170
  console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
16658
17171
  await ctx.collections.broadcasts.updateOne(
16659
- { _id: broadcast._id },
16660
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
17172
+ { _id: broadcast._id, status: "sending" },
17173
+ {
17174
+ $set: {
17175
+ status: "failed",
17176
+ failureReason: `dispatch error: ${String(err?.message ?? err)}`,
17177
+ updatedAt: /* @__PURE__ */ new Date(),
17178
+ dispatchLeaseId: null
17179
+ }
17180
+ }
16661
17181
  );
16662
17182
  }
16663
17183
  }
17184
+ function broadcastDedupeKey(broadcastId, externalId) {
17185
+ return `broadcast:${broadcastId}:${externalId}`;
17186
+ }
17187
+ var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17188
+ function isSendableEmail(email) {
17189
+ return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
17190
+ }
17191
+ async function* eligibleRecipientPages(broadcast, kind, ctx) {
17192
+ const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
17193
+ const sort = broadcast.order ?? void 0;
17194
+ if (sort && !ctx.adapter.supportsSort) {
17195
+ throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
17196
+ }
17197
+ let cursor;
17198
+ for (; ; ) {
17199
+ const page = await ctx.adapter.query(hostFilter, {
17200
+ limit: ctx.config.broadcastEnqueueBatchSize,
17201
+ cursor,
17202
+ ...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
17203
+ });
17204
+ if (page.contacts.length === 0) break;
17205
+ const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
17206
+ const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
17207
+ yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
17208
+ if (!page.nextCursor) break;
17209
+ cursor = page.nextCursor;
17210
+ }
17211
+ }
17212
+ async function alreadyDispatched(ctx, broadcastId, contacts) {
17213
+ if (contacts.length === 0) return /* @__PURE__ */ new Set();
17214
+ const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
17215
+ const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
17216
+ return new Set(found.map(String));
17217
+ }
16664
17218
  async function dispatchBroadcast(broadcast, ctx) {
16665
17219
  const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
16666
- if (!template) {
17220
+ const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
17221
+ if (failure || !template) {
16667
17222
  await ctx.collections.broadcasts.updateOne(
16668
- { _id: broadcast._id },
16669
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
17223
+ { _id: broadcast._id, status: "sending" },
17224
+ { $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
16670
17225
  );
16671
17226
  return;
16672
17227
  }
16673
- const hostFilter = toAdapterFilter(broadcast.segmentDefinition);
16674
- const postFilters = broadcast.segmentDefinition.filters.filter((f) => isMailerSide(f));
16675
- let cursor = void 0;
16676
- let total = 0;
16677
- const batchSize = ctx.config.broadcastEnqueueBatchSize;
17228
+ const leaseId = new mongodb.ObjectId().toHexString();
17229
+ const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
17230
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
17231
+ {
17232
+ _id: broadcast._id,
17233
+ status: "sending",
17234
+ $or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
17235
+ },
17236
+ { $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
17237
+ { returnDocument: "after" }
17238
+ );
17239
+ if (!b) return;
17240
+ const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
17241
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
17242
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
17243
+ )).matchedCount === 1;
16678
17244
  const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
16679
- const respectTimezone = broadcast.respectRecipientTimezone === true;
16680
- const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
16681
- for (; ; ) {
16682
- await ctx.collections.broadcasts.updateOne(
16683
- { _id: broadcast._id },
16684
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16685
- );
16686
- const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
16687
- if (page.contacts.length === 0) break;
16688
- const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
16689
- if (eligible.length > 0) {
17245
+ const respectTimezone = b.respectRecipientTimezone === true;
17246
+ const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
17247
+ const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
17248
+ let capReached = false;
17249
+ try {
17250
+ for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
17251
+ if (!await holdsLease()) return;
17252
+ const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
17253
+ if (bucket?.status === "tripped") {
17254
+ await pauseBroadcast(ctx, b._id, {
17255
+ code: "circuit_breaker",
17256
+ message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
17257
+ at: /* @__PURE__ */ new Date(),
17258
+ details: { bucket: bucket._id }
17259
+ });
17260
+ return;
17261
+ }
17262
+ const seen = await alreadyDispatched(ctx, b._id, eligible);
17263
+ let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
17264
+ if (fresh.length === 0) continue;
17265
+ if (cap !== null) {
17266
+ const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
17267
+ if (room <= 0) {
17268
+ capReached = true;
17269
+ break;
17270
+ }
17271
+ if (fresh.length > room) {
17272
+ fresh = fresh.slice(0, room);
17273
+ capReached = true;
17274
+ }
17275
+ }
16690
17276
  while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
16691
- await ctx.collections.broadcasts.updateOne(
16692
- { _id: broadcast._id },
16693
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
16694
- );
17277
+ if (!await holdsLease()) return;
16695
17278
  await sleep(2e3);
16696
17279
  }
16697
- const sendDocs = await Promise.all(
16698
- eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
16699
- );
16700
17280
  const inserted = [];
16701
- for (const { doc, delayMs } of sendDocs) {
16702
- if (!doc) continue;
17281
+ for (const contact of fresh) {
17282
+ const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
16703
17283
  try {
16704
17284
  await ctx.collections.sends.insertOne(doc);
16705
17285
  inserted.push({ sendId: doc._id, delayMs });
@@ -16707,115 +17287,51 @@ async function dispatchBroadcast(broadcast, ctx) {
16707
17287
  if (err?.code !== 11e3) throw err;
16708
17288
  }
16709
17289
  }
16710
- if (inserted.length > 0) {
16711
- await Promise.all(
16712
- inserted.map(
16713
- ({ sendId, delayMs }) => ctx.queues.send.add(
16714
- "send",
16715
- { sendId: String(sendId) },
16716
- {
16717
- attempts: ctx.config.sendRetryAttempts,
16718
- backoff: { type: "exponential", delay: 6e4 },
16719
- ...delayMs > 0 ? { delay: delayMs } : {}
16720
- }
16721
- )
17290
+ await Promise.all(
17291
+ inserted.map(
17292
+ ({ sendId, delayMs }) => ctx.queues.send.add(
17293
+ "send",
17294
+ { sendId: String(sendId) },
17295
+ {
17296
+ attempts: ctx.config.sendRetryAttempts,
17297
+ backoff: { type: "exponential", delay: 6e4 },
17298
+ ...delayMs > 0 ? { delay: delayMs } : {}
17299
+ }
16722
17300
  )
16723
- );
16724
- }
16725
- total += inserted.length;
16726
- }
16727
- if (!page.nextCursor) break;
16728
- cursor = page.nextCursor;
16729
- }
16730
- await ctx.collections.broadcasts.updateOne(
16731
- { _id: broadcast._id },
16732
- {
16733
- $set: {
16734
- status: "sent",
16735
- completedAt: /* @__PURE__ */ new Date(),
16736
- recipientCount: total,
16737
- updatedAt: /* @__PURE__ */ new Date()
16738
- }
16739
- }
16740
- );
16741
- }
16742
- function toAdapterFilter(seg) {
16743
- const out = {};
16744
- for (const f of seg.filters) {
16745
- switch (f.kind) {
16746
- case "hasTag":
16747
- out.hasTag = f.tag;
16748
- break;
16749
- case "fieldEquals":
16750
- out.fieldEquals = { field: f.field, value: f.value };
16751
- break;
16752
- case "fieldIn":
16753
- out.fieldIn = { field: f.field, values: f.values };
16754
- break;
16755
- case "fieldExists":
16756
- out.fieldExists = f.field;
16757
- break;
16758
- }
16759
- }
16760
- return out;
16761
- }
16762
- function isMailerSide(f) {
16763
- 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";
16764
- }
16765
- async function applyPostFilters(contacts, filters, ctx) {
16766
- if (filters.length === 0) return contacts;
16767
- const externalIds = contacts.map((c) => c.externalId);
16768
- const cache = {};
16769
- for (const f of filters) {
16770
- if (f.kind === "subscriptionStatus") {
16771
- const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
16772
- cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
16773
- }
16774
- if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
16775
- const query = { externalId: { $in: externalIds }, name: f.eventName };
16776
- if (f.withinDays) {
16777
- query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
16778
- }
16779
- const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
16780
- cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
17301
+ )
17302
+ );
17303
+ if (capReached) break;
16781
17304
  }
16782
- }
16783
- return contacts.filter((c) => filters.every((f) => filterMatches(c, f, cache)));
16784
- }
16785
- function filterMatches(c, f, cache) {
16786
- switch (f.kind) {
16787
- case "subscriptionStatus":
16788
- return cache[`sub:${f.equals}`]?.has(c.externalId) ?? false;
16789
- case "firedEvent":
16790
- return cache[`evt:${f.eventName}`]?.has(c.externalId) ?? false;
16791
- case "notFiredEvent":
16792
- return !cache[`evt:${f.eventName}`]?.has(c.externalId);
16793
- case "notHasTag":
16794
- return !c.tags.includes(f.tag);
16795
- case "opened":
16796
- case "notOpened":
16797
- return true;
16798
- case "subscribedAfter":
16799
- case "subscribedBefore":
16800
- return true;
16801
- // V2
16802
- case "any":
16803
- return f.filters.some((sub) => filterMatches(c, sub, cache));
16804
- case "not":
16805
- return !filterMatches(c, f.filter, cache);
16806
- default:
16807
- return true;
17305
+ const now = /* @__PURE__ */ new Date();
17306
+ const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
17307
+ const set2 = capReached ? {
17308
+ status: "paused",
17309
+ pausedAt: now,
17310
+ pauseReason: {
17311
+ code: "cap_reached",
17312
+ message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
17313
+ at: now,
17314
+ details: { recipientCap: cap, sendsSoFar: total }
17315
+ }
17316
+ } : { status: "sent", completedAt: now };
17317
+ await ctx.collections.broadcasts.updateOne(
17318
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
17319
+ { $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
17320
+ );
17321
+ } finally {
17322
+ await ctx.collections.broadcasts.updateOne(
17323
+ { _id: b._id, dispatchLeaseId: leaseId },
17324
+ { $set: { dispatchLeaseId: null } }
17325
+ );
16808
17326
  }
16809
17327
  }
16810
- async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
16811
- const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
16812
- if (supp.suppressed) return { doc: null, delayMs: 0 };
17328
+ function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
16813
17329
  const sendId = new mongodb.ObjectId();
16814
- const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
16815
- let delayMs = Math.max(0, scheduledMs - Date.now());
17330
+ const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
17331
+ const now = Date.now();
17332
+ let delayMs = Math.max(0, scheduledMs - now);
16816
17333
  if (respectTimezone && contact.timezone) {
16817
- const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
16818
- delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
17334
+ delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
16819
17335
  }
16820
17336
  const doc = {
16821
17337
  _id: sendId,
@@ -16850,16 +17366,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
16850
17366
  queuedAt: /* @__PURE__ */ new Date(),
16851
17367
  updatedAt: /* @__PURE__ */ new Date(),
16852
17368
  sentAt: null,
16853
- deliveredAt: null
17369
+ deliveredAt: null,
17370
+ notBefore: new Date(Date.now() + delayMs)
16854
17371
  };
16855
17372
  return { doc, delayMs };
16856
17373
  }
17374
+ var DAY_MS = 24 * 60 * 60 * 1e3;
17375
+ var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
17376
+ function recipientSlotMs(scheduledMs, timezone, nowMs) {
17377
+ let anchor = scheduledMs;
17378
+ let target = anchor + perRecipientOffsetMs(anchor, timezone);
17379
+ const earliest = nowMs - TZ_SLOT_GRACE_MS;
17380
+ if (target < earliest) {
17381
+ anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
17382
+ target = anchor + perRecipientOffsetMs(anchor, timezone);
17383
+ if (target < earliest) target += DAY_MS;
17384
+ }
17385
+ return target;
17386
+ }
16857
17387
  function perRecipientOffsetMs(scheduledMs, timezone) {
16858
- const DAY_MS = 24 * 60 * 60 * 1e3;
16859
17388
  try {
16860
17389
  const scheduled = new Date(scheduledMs);
16861
- const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
16862
- const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hour12: false });
17390
+ const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
17391
+ const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
16863
17392
  const parse = (s) => {
16864
17393
  const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
16865
17394
  if (!m) return 0;
@@ -16980,9 +17509,9 @@ async function runDnsblChecks(ctx, opts = {}) {
16980
17509
  let listedCount = 0;
16981
17510
  async function processPair(p) {
16982
17511
  const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
16983
- const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
16984
- if (lookup.transient) return;
16985
- if (lookup.result === "listed") listedCount++;
17512
+ const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
17513
+ if (lookup2.transient) return;
17514
+ if (lookup2.result === "listed") listedCount++;
16986
17515
  await ctx.collections.dnsblChecks.updateOne(
16987
17516
  { target: p.target, list: p.list.host },
16988
17517
  {
@@ -16991,9 +17520,9 @@ async function runDnsblChecks(ctx, opts = {}) {
16991
17520
  targetKind: p.targetKind,
16992
17521
  list: p.list.host,
16993
17522
  listLabel: p.list.label,
16994
- result: lookup.result,
16995
- returnCodes: lookup.returnCodes,
16996
- errorMessage: lookup.errorMessage,
17523
+ result: lookup2.result,
17524
+ returnCodes: lookup2.returnCodes,
17525
+ errorMessage: lookup2.errorMessage,
16997
17526
  runAt: /* @__PURE__ */ new Date()
16998
17527
  }
16999
17528
  },
@@ -17834,6 +18363,9 @@ async function runTick(ctx) {
17834
18363
  await resumeStalledBroadcasts(ctx).catch((err) => {
17835
18364
  console.error("mailery: stalled-broadcast resume failed", err);
17836
18365
  });
18366
+ await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
18367
+ console.error("mailery: broadcast stop-rule sweep failed", err);
18368
+ });
17837
18369
  await evaluateHealth(ctx).catch((err) => {
17838
18370
  console.error("mailery: health evaluation failed", err);
17839
18371
  });
@@ -18538,6 +19070,25 @@ var Mailer = class _Mailer {
18538
19070
  };
18539
19071
 
18540
19072
  // src/testing/memory-adapter.ts
19073
+ function fieldPath(c, path2) {
19074
+ let cur = c.fields;
19075
+ for (const part of path2.split(".")) {
19076
+ if (cur === null || typeof cur !== "object") return void 0;
19077
+ cur = cur[part];
19078
+ }
19079
+ return cur;
19080
+ }
19081
+ function compareValues(a, b) {
19082
+ const an = a === null || a === void 0;
19083
+ const bn = b === null || b === void 0;
19084
+ if (an || bn) return an && bn ? 0 : an ? -1 : 1;
19085
+ const av = a instanceof Date ? a.getTime() : a;
19086
+ const bv = b instanceof Date ? b.getTime() : b;
19087
+ if (typeof av === "number" && typeof bv === "number") return av - bv;
19088
+ const as = String(av);
19089
+ const bs = String(bv);
19090
+ return as < bs ? -1 : as > bs ? 1 : 0;
19091
+ }
18541
19092
  var MemoryContactAdapter = class {
18542
19093
  byId = /* @__PURE__ */ new Map();
18543
19094
  constructor(initial = []) {
@@ -18572,8 +19123,29 @@ var MemoryContactAdapter = class {
18572
19123
  }
18573
19124
  return out;
18574
19125
  }
19126
+ /** `query` honours `opts.sort` over `contact.fields` (dotted paths), like MongoContactAdapter over documents. */
19127
+ supportsSort = true;
18575
19128
  async query(filter, opts) {
18576
- const all = Array.from(this.byId.values()).filter((c) => matches(c, filter)).sort((a, b) => a.externalId < b.externalId ? -1 : 1);
19129
+ if (opts.sort) {
19130
+ const { field, direction } = opts.sort;
19131
+ const dir = direction === "desc" ? -1 : 1;
19132
+ const sorted = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => {
19133
+ const byValue = compareValues(fieldPath(a, field), fieldPath(b, field)) * dir;
19134
+ if (byValue !== 0) return byValue;
19135
+ return a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0;
19136
+ });
19137
+ let start = 0;
19138
+ if (opts.cursor) {
19139
+ const at = sorted.findIndex((c) => c.externalId === opts.cursor);
19140
+ start = at === -1 ? sorted.length : at + 1;
19141
+ }
19142
+ const page = sorted.slice(start, start + opts.limit);
19143
+ return {
19144
+ contacts: page,
19145
+ nextCursor: start + opts.limit < sorted.length ? page[page.length - 1]?.externalId : void 0
19146
+ };
19147
+ }
19148
+ const all = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => a.externalId < b.externalId ? -1 : 1);
18577
19149
  const startIndex = opts.cursor ? all.findIndex((c) => c.externalId > opts.cursor) : 0;
18578
19150
  const slice = all.slice(startIndex === -1 ? all.length : startIndex, (startIndex === -1 ? all.length : startIndex) + opts.limit);
18579
19151
  const hasMore = startIndex !== -1 && startIndex + opts.limit < all.length;
@@ -18584,15 +19156,15 @@ var MemoryContactAdapter = class {
18584
19156
  }
18585
19157
  async count(filter) {
18586
19158
  let n = 0;
18587
- for (const c of this.byId.values()) if (matches(c, filter)) n++;
19159
+ for (const c of this.byId.values()) if (matches2(c, filter)) n++;
18588
19160
  return n;
18589
19161
  }
18590
19162
  async addTags(externalId, tags) {
18591
19163
  const c = this.byId.get(externalId);
18592
19164
  if (!c) return;
18593
- const set = new Set(c.tags);
18594
- for (const t of tags) set.add(t);
18595
- c.tags = Array.from(set);
19165
+ const set2 = new Set(c.tags);
19166
+ for (const t of tags) set2.add(t);
19167
+ c.tags = Array.from(set2);
18596
19168
  }
18597
19169
  async removeTags(externalId, tags) {
18598
19170
  const c = this.byId.get(externalId);
@@ -18601,7 +19173,7 @@ var MemoryContactAdapter = class {
18601
19173
  c.tags = c.tags.filter((t) => !remove.has(t));
18602
19174
  }
18603
19175
  };
18604
- function matches(c, f) {
19176
+ function matches2(c, f) {
18605
19177
  if (f.emailIn && !f.emailIn.map((e) => e.toLowerCase()).includes(c.email)) return false;
18606
19178
  if (f.externalIdIn && !f.externalIdIn.includes(c.externalId)) return false;
18607
19179
  if (f.fieldEquals && c.fields[f.fieldEquals.field] !== f.fieldEquals.value) return false;