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/index.js CHANGED
@@ -88,6 +88,42 @@ __export(mongo_exports, {
88
88
  function canBeObjectId(s) {
89
89
  return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
90
90
  }
91
+ function getPath(doc, path3) {
92
+ let cur = doc;
93
+ for (const part of path3.split(".")) {
94
+ if (cur === null || typeof cur !== "object") return void 0;
95
+ cur = cur[part];
96
+ }
97
+ return cur;
98
+ }
99
+ function isObjectIdLike(v) {
100
+ return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
101
+ }
102
+ function packScalar(v) {
103
+ if (v instanceof Date) return { d: v.toISOString() };
104
+ if (isObjectIdLike(v)) return { o: v.toHexString() };
105
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
106
+ return null;
107
+ }
108
+ function unpackScalar(v) {
109
+ if (v && typeof v === "object") {
110
+ if ("d" in v) return new Date(v.d);
111
+ if ("o" in v) return new ObjectId(v.o);
112
+ }
113
+ return v;
114
+ }
115
+ function encodeSortCursor(value, id) {
116
+ return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
117
+ }
118
+ function decodeSortCursor(cursor) {
119
+ try {
120
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
121
+ if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
122
+ return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
91
127
  var MongoContactAdapter;
92
128
  var init_mongo = __esm({
93
129
  "src/server/adapters/mongo.ts"() {
@@ -137,7 +173,10 @@ var init_mongo = __esm({
137
173
  }
138
174
  return out;
139
175
  }
176
+ /** `query` honours `opts.sort` — see `querySorted`. */
177
+ supportsSort = true;
140
178
  async query(filter, opts) {
179
+ if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
141
180
  const query = this.translateFilterFn(filter);
142
181
  const limit = Math.min(opts.limit, this.batchSize);
143
182
  if (opts.cursor) {
@@ -156,6 +195,52 @@ var init_mongo = __esm({
156
195
  const query = this.translateFilterFn(filter);
157
196
  return await this.col.countDocuments(query);
158
197
  }
198
+ /**
199
+ * Keyset pagination in (sort.field, idField) order. The cursor encodes the
200
+ * last row's sort value and id, and the next page is every row strictly
201
+ * after that position. Mongo sorts a null or missing value lowest, so
202
+ * `desc` puts contacts without the field last and `asc` puts them first;
203
+ * the position predicate follows the same rule, so they are neither
204
+ * skipped nor repeated.
205
+ *
206
+ * The field should hold one BSON type (or be absent): range operators only
207
+ * compare within a type. A value that moves while a pass is paging (a
208
+ * contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
209
+ * at all in that pass; broadcast dispatch tolerates both (the per-recipient
210
+ * dedupe key stops a second send, and a later pass picks up a skipped one).
211
+ * Index `{ <field>: -1, <idField>: 1 }` on a large collection.
212
+ */
213
+ async querySorted(filter, limitIn, cursor, sort) {
214
+ const base = this.translateFilterFn(filter);
215
+ const limit = Math.min(limitIn, this.batchSize);
216
+ const dir = sort.direction === "desc" ? -1 : 1;
217
+ let query = base;
218
+ if (cursor) {
219
+ const pos = decodeSortCursor(cursor);
220
+ if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
221
+ query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
222
+ }
223
+ const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
224
+ const hasMore = docs.length > limit;
225
+ const slice = hasMore ? docs.slice(0, limit) : docs;
226
+ const last = slice[slice.length - 1];
227
+ return {
228
+ contacts: slice.map((d) => this.toContactFn(d)),
229
+ nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
230
+ };
231
+ }
232
+ afterPosition(field, dir, value, id) {
233
+ const idF = this.idField;
234
+ const isNull = value === null || value === void 0;
235
+ if (dir === -1) {
236
+ if (isNull) return { [field]: null, [idF]: { $gt: id } };
237
+ return {
238
+ $or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
239
+ };
240
+ }
241
+ if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
242
+ return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
243
+ }
159
244
  addTags;
160
245
  removeTags;
161
246
  // -------------------------------------------------------------------------
@@ -505,6 +590,35 @@ var sendOneOffInputSchema = z.object({
505
590
  providerOverride: z.string().optional(),
506
591
  dedupeKey: z.string().min(1).max(512)
507
592
  });
593
+ var subscriptionStatusEnum = z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
594
+ var fieldNameSchema = (strict) => (strict ? z.string().min(1) : z.string()).max(256).regex(/^(?!\$)[^\0]*$/, "must not start with $");
595
+ var segmentValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
596
+ function segmentFilterSchema(strict) {
597
+ const str = strict ? z.string().min(1).max(256) : z.string().max(256);
598
+ const days = z.number().int().positive().max(36500).optional();
599
+ const self = z.lazy(
600
+ () => z.discriminatedUnion("kind", [
601
+ z.object({ kind: z.literal("fieldEquals"), field: fieldNameSchema(strict), value: segmentValueSchema }),
602
+ z.object({ kind: z.literal("fieldIn"), field: fieldNameSchema(strict), values: z.array(segmentValueSchema).max(1e3) }),
603
+ z.object({ kind: z.literal("fieldExists"), field: fieldNameSchema(strict) }),
604
+ z.object({ kind: z.literal("hasTag"), tag: str }),
605
+ z.object({ kind: z.literal("notHasTag"), tag: str }),
606
+ z.object({ kind: z.literal("subscriptionStatus"), equals: subscriptionStatusEnum }),
607
+ z.object({ kind: z.literal("firedEvent"), eventName: str, withinDays: days }),
608
+ z.object({ kind: z.literal("notFiredEvent"), eventName: str, withinDays: days }),
609
+ z.object({ kind: z.literal("subscribedAfter"), date: z.coerce.date() }),
610
+ z.object({ kind: z.literal("subscribedBefore"), date: z.coerce.date() }),
611
+ z.object({ kind: z.literal("opened"), templateSlug: slugSchema.optional(), withinDays: days }),
612
+ z.object({ kind: z.literal("notOpened"), templateSlug: slugSchema.optional(), withinDays: days }),
613
+ z.object({ kind: z.literal("any"), filters: strict ? z.array(self).min(1).max(50) : z.array(self).max(50) }),
614
+ z.object({ kind: z.literal("not"), filter: self })
615
+ ])
616
+ );
617
+ return self;
618
+ }
619
+ function segmentDefinitionSchema(strict) {
620
+ return z.object({ filters: z.array(segmentFilterSchema(strict)).max(50) });
621
+ }
508
622
  var flowStepSchema = z.lazy(
509
623
  () => z.discriminatedUnion("type", [
510
624
  z.object({
@@ -673,6 +787,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
673
787
  windowMinutes: 60,
674
788
  minSendsBeforeEval: 100
675
789
  };
790
+ var BROADCAST_STOP_RULE_DEFAULTS = {
791
+ enabled: true,
792
+ hardBounceRatePct: 2,
793
+ complaintRatePct: 0.1,
794
+ unsubscribeRatePct: 1,
795
+ minSample: 100
796
+ };
676
797
  function resolveConfig(c) {
677
798
  return {
678
799
  ...c,
@@ -700,7 +821,8 @@ function resolveConfig(c) {
700
821
  storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
701
822
  requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
702
823
  trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
703
- circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
824
+ circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
825
+ broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
704
826
  };
705
827
  }
706
828
 
@@ -918,11 +1040,13 @@ function resolveProvider(providers, name) {
918
1040
  function registeredProviderNames(providers) {
919
1041
  return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
920
1042
  }
1043
+ var SEND_ID_RE = /^[a-f0-9]{24}$/i;
921
1044
  function signUnsubscribeToken(payload, secret) {
922
1045
  const body = JSON.stringify({
923
1046
  e: payload.email.toLowerCase(),
924
1047
  s: payload.scope,
925
- x: payload.expiresAt.getTime()
1048
+ x: payload.expiresAt.getTime(),
1049
+ ...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
926
1050
  });
927
1051
  const bodyB64 = b64url(Buffer.from(body, "utf8"));
928
1052
  const hmac = crypto2.createHmac("sha256", secret).update(bodyB64).digest();
@@ -953,7 +1077,8 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
953
1077
  return {
954
1078
  email: body.e,
955
1079
  scope: body.s,
956
- expiresAt: new Date(body.x)
1080
+ expiresAt: new Date(body.x),
1081
+ ...typeof body.i === "string" && SEND_ID_RE.test(body.i) ? { sendId: body.i } : {}
957
1082
  };
958
1083
  }
959
1084
  var TRACKING_SCOPES = ["open", "click"];
@@ -1728,6 +1853,211 @@ function effectiveOverallStatus(docs) {
1728
1853
  if (buckets.some((d) => d.status === "degraded")) return "degraded";
1729
1854
  return "healthy";
1730
1855
  }
1856
+ function emptyBroadcastStats() {
1857
+ return withRates({
1858
+ total: 0,
1859
+ accepted: 0,
1860
+ delivered: 0,
1861
+ bounced: 0,
1862
+ hardBounced: 0,
1863
+ softBounced: 0,
1864
+ complained: 0,
1865
+ unsubscribed: 0,
1866
+ opened: 0,
1867
+ clicked: 0,
1868
+ outcomes: 0
1869
+ });
1870
+ }
1871
+ var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
1872
+ function withRates(c) {
1873
+ return {
1874
+ ...c,
1875
+ rates: {
1876
+ deliveryRatePct: pct(c.delivered, c.accepted),
1877
+ bounceRatePct: pct(c.bounced, c.outcomes),
1878
+ hardBounceRatePct: pct(c.hardBounced, c.outcomes),
1879
+ complaintRatePct: pct(c.complained, c.outcomes),
1880
+ unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
1881
+ openRatePct: pct(c.opened, c.delivered),
1882
+ clickRatePct: pct(c.clicked, c.delivered)
1883
+ }
1884
+ };
1885
+ }
1886
+ var set = (field) => ({ $ifNull: [field, false] });
1887
+ var DELIVERED = {
1888
+ $or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
1889
+ };
1890
+ var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
1891
+ var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
1892
+ async function aggregateBroadcastStats(collections, broadcastId) {
1893
+ const rows = await collections.sends.aggregate([
1894
+ { $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
1895
+ {
1896
+ $group: {
1897
+ _id: "$broadcastId",
1898
+ total: { $sum: 1 },
1899
+ accepted: count(set("$sentAt")),
1900
+ delivered: count(DELIVERED),
1901
+ bounced: count(BOUNCED),
1902
+ hardBounced: count({ $eq: ["$bounceType", "hard"] }),
1903
+ softBounced: count({ $eq: ["$bounceType", "soft"] }),
1904
+ complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
1905
+ unsubscribed: count(set("$unsubscribedAt")),
1906
+ opened: count(set("$openedAt")),
1907
+ clicked: count(set("$firstClickAt")),
1908
+ outcomes: count({ $or: [DELIVERED, BOUNCED] })
1909
+ }
1910
+ }
1911
+ ]).toArray();
1912
+ const out = /* @__PURE__ */ new Map();
1913
+ for (const { _id, ...c } of rows) {
1914
+ if (_id) out.set(String(_id), withRates(c));
1915
+ }
1916
+ return out;
1917
+ }
1918
+ async function broadcastStatsFor(collections, broadcastId) {
1919
+ return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
1920
+ }
1921
+ function effectiveStopRules(config, b) {
1922
+ return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
1923
+ }
1924
+ var RULE_COUNTS = [
1925
+ ["complaintRatePct", "complained"],
1926
+ ["hardBounceRatePct", "hardBounced"],
1927
+ ["unsubscribeRatePct", "unsubscribed"]
1928
+ ];
1929
+ function evaluateStopRules(stats, rules) {
1930
+ const sample = stats.outcomes;
1931
+ const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
1932
+ const breaches = [];
1933
+ if (evaluated) {
1934
+ for (const [rule, key] of RULE_COUNTS) {
1935
+ const n = stats[key];
1936
+ const exactPct = n / sample * 100;
1937
+ if (exactPct > rules[rule]) {
1938
+ breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
1939
+ }
1940
+ }
1941
+ }
1942
+ return { rules, sample, evaluated, breaches };
1943
+ }
1944
+ var RULE_LABEL = {
1945
+ complaintRatePct: "complaint rate",
1946
+ hardBounceRatePct: "hard bounce rate",
1947
+ unsubscribeRatePct: "unsubscribe rate"
1948
+ };
1949
+ var PAUSE_PRECEDENCE = {
1950
+ manual: 1,
1951
+ cap_reached: 1,
1952
+ stop_rule: 2,
1953
+ circuit_breaker: 2
1954
+ };
1955
+ async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
1956
+ const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
1957
+ (k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
1958
+ );
1959
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
1960
+ {
1961
+ _id: broadcastId,
1962
+ $or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
1963
+ },
1964
+ { $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
1965
+ { returnDocument: "after" }
1966
+ );
1967
+ if (!b) return { paused: false, heldSends: 0 };
1968
+ const held = await holdQueuedSends(ctx, broadcastId, reason.code);
1969
+ if (ctx.audit) {
1970
+ await ctx.audit({
1971
+ actor,
1972
+ action: "broadcast.pause",
1973
+ resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
1974
+ diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
1975
+ }).catch(() => {
1976
+ });
1977
+ }
1978
+ if (ctx.config.onBroadcastPaused) {
1979
+ try {
1980
+ await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
1981
+ } catch {
1982
+ }
1983
+ }
1984
+ return { paused: true, heldSends: held };
1985
+ }
1986
+ async function holdQueuedSends(ctx, broadcastId, code) {
1987
+ const res = await ctx.collections.sends.updateMany(
1988
+ { broadcastId, status: "queued" },
1989
+ { $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
1990
+ );
1991
+ return res.modifiedCount;
1992
+ }
1993
+ async function releaseHeldSends(ctx, broadcastId) {
1994
+ const held = await ctx.collections.sends.find({ broadcastId, status: "held" }, { projection: { _id: 1, notBefore: 1 } }).toArray();
1995
+ if (held.length === 0) return 0;
1996
+ await ctx.collections.sends.updateMany(
1997
+ { _id: { $in: held.map((s) => s._id) }, status: "held" },
1998
+ { $set: { status: "queued", errorMessage: null, updatedAt: /* @__PURE__ */ new Date() } }
1999
+ );
2000
+ const now = Date.now();
2001
+ await Promise.all(
2002
+ held.map((s) => {
2003
+ const delay = s.notBefore ? Math.max(0, new Date(s.notBefore).getTime() - now) : 0;
2004
+ return ctx.queues.send.add(
2005
+ "send",
2006
+ { sendId: String(s._id) },
2007
+ {
2008
+ attempts: ctx.config.sendRetryAttempts,
2009
+ backoff: { type: "exponential", delay: 6e4 },
2010
+ ...delay > 0 ? { delay } : {}
2011
+ }
2012
+ );
2013
+ })
2014
+ );
2015
+ return held.length;
2016
+ }
2017
+ async function evaluateBroadcastStopRules(ctx, broadcastId) {
2018
+ const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
2019
+ if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
2020
+ if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
2021
+ const stats = await broadcastStatsFor(ctx.collections, broadcastId);
2022
+ const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
2023
+ if (evaluation.breaches.length === 0) return evaluation;
2024
+ const now = /* @__PURE__ */ new Date();
2025
+ if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
2026
+ await ctx.collections.broadcasts.updateOne(
2027
+ { _id: broadcastId, stopRuleBreach: { $in: [null] } },
2028
+ { $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
2029
+ );
2030
+ return evaluation;
2031
+ }
2032
+ const first = evaluation.breaches[0];
2033
+ await pauseBroadcast(ctx, broadcastId, {
2034
+ code: "stop_rule",
2035
+ message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
2036
+ at: now,
2037
+ details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
2038
+ });
2039
+ return evaluation;
2040
+ }
2041
+ var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
2042
+ async function evaluateActiveBroadcastStopRules(ctx) {
2043
+ const live = await ctx.collections.broadcasts.find(
2044
+ { status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
2045
+ { projection: { _id: 1 } }
2046
+ ).limit(200).toArray();
2047
+ for (const { _id } of live) {
2048
+ await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
2049
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
2050
+ });
2051
+ }
2052
+ }
2053
+ async function attributeUnsubscribeToSend(ctx, sendId, email) {
2054
+ if (!ObjectId.isValid(sendId)) return;
2055
+ const _id = new ObjectId(sendId);
2056
+ const send = await ctx.collections.sends.findOne({ _id }, { projection: { emailAtSend: 1, broadcastId: 1 } });
2057
+ if (!send || send.emailAtSend.toLowerCase() !== email.toLowerCase()) return;
2058
+ await ctx.collections.sends.updateOne({ _id, unsubscribedAt: null }, { $set: { unsubscribedAt: /* @__PURE__ */ new Date() } });
2059
+ if (send.broadcastId) await evaluateBroadcastStopRules(ctx, send.broadcastId);
2060
+ }
1731
2061
 
1732
2062
  // src/server/runner/webhook.ts
1733
2063
  function dimsFromSend(send) {
@@ -1882,6 +2212,11 @@ async function applyWebhookEvent(event, ctx) {
1882
2212
  );
1883
2213
  break;
1884
2214
  }
2215
+ if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
2216
+ await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
2217
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
2218
+ });
2219
+ }
1885
2220
  }
1886
2221
  async function suppressOnce(ctx, email, reason, scope) {
1887
2222
  const normalized = email.toLowerCase();
@@ -2293,11 +2628,30 @@ async function isSuppressed(collections, email, kind) {
2293
2628
  if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
2294
2629
  const hashed = await collections.suppressions.findOne({
2295
2630
  emailHash: sha256Hex(normalized),
2296
- scope: { $in: allowed }
2631
+ scope: { $in: allowed },
2632
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
2297
2633
  });
2298
2634
  if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
2299
2635
  return { suppressed: false };
2300
2636
  }
2637
+ async function suppressedEmails(collections, emails, kind) {
2638
+ const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
2639
+ if (normalized.length === 0) return /* @__PURE__ */ new Set();
2640
+ const allowed = SCOPES_BY_KIND[kind];
2641
+ const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
2642
+ const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
2643
+ const [plain, hashed] = await Promise.all([
2644
+ collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
2645
+ collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
2646
+ ]);
2647
+ const out = /* @__PURE__ */ new Set();
2648
+ for (const e of plain) if (typeof e === "string") out.add(e);
2649
+ for (const h of hashed) {
2650
+ const e = byHash.get(String(h));
2651
+ if (e) out.add(e);
2652
+ }
2653
+ return out;
2654
+ }
2301
2655
 
2302
2656
  // src/server/runner/send.ts
2303
2657
  async function handleSend(run, step, contact, flow, ctx) {
@@ -2374,6 +2728,27 @@ async function dispatchSend(sendId, ctx) {
2374
2728
  await markFailed(send._id, "template_missing", ctx);
2375
2729
  return;
2376
2730
  }
2731
+ if (send.broadcastId) {
2732
+ const broadcast = await ctx.collections.broadcasts.findOne(
2733
+ { _id: send.broadcastId },
2734
+ { projection: { status: 1, pauseReason: 1 } }
2735
+ );
2736
+ const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
2737
+ if (holding || broadcast?.status === "cancelled") {
2738
+ const held = holding;
2739
+ await ctx.collections.sends.updateOne(
2740
+ { _id: send._id },
2741
+ {
2742
+ $set: {
2743
+ status: held ? "held" : "cancelled",
2744
+ errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
2745
+ updatedAt: /* @__PURE__ */ new Date()
2746
+ }
2747
+ }
2748
+ );
2749
+ return;
2750
+ }
2751
+ }
2377
2752
  const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
2378
2753
  if (supp.suppressed) {
2379
2754
  await ctx.collections.sends.updateOne(
@@ -2384,6 +2759,19 @@ async function dispatchSend(sendId, ctx) {
2384
2759
  }
2385
2760
  if (send.kind === "marketing") {
2386
2761
  const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
2762
+ if (bucket?.status === "tripped" && send.broadcastId) {
2763
+ await ctx.collections.sends.updateOne(
2764
+ { _id: send._id },
2765
+ { $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
2766
+ );
2767
+ await pauseBroadcast(ctx, send.broadcastId, {
2768
+ code: "circuit_breaker",
2769
+ message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
2770
+ at: /* @__PURE__ */ new Date(),
2771
+ details: { bucket: bucket._id }
2772
+ });
2773
+ return;
2774
+ }
2387
2775
  if (bucket?.status === "tripped") {
2388
2776
  await ctx.collections.sends.updateOne(
2389
2777
  { _id: send._id },
@@ -2416,7 +2804,7 @@ async function dispatchSend(sendId, ctx) {
2416
2804
  eventName: run?.triggerEvent?.name,
2417
2805
  eventProperties: run?.triggerEvent?.properties
2418
2806
  });
2419
- renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
2807
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
2420
2808
  rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
2421
2809
  } catch (err) {
2422
2810
  await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
@@ -2507,11 +2895,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
2507
2895
  }
2508
2896
  return ctx.config.defaultProvider;
2509
2897
  }
2510
- function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
2898
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
2511
2899
  const scope = "marketing";
2512
2900
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
2513
2901
  const token = signUnsubscribeToken(
2514
- { email: contact.email, scope, expiresAt },
2902
+ { email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
2515
2903
  ctx.config.unsubscribeSecret
2516
2904
  );
2517
2905
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
@@ -2887,6 +3275,184 @@ async function sweepStrandedFlowRuns(ctx) {
2887
3275
  }
2888
3276
  }
2889
3277
  }
3278
+
3279
+ // src/server/runner/segment.ts
3280
+ function planSegment(seg) {
3281
+ const hostFilter = {};
3282
+ const postFilters = [];
3283
+ const hostFields = /* @__PURE__ */ new Set();
3284
+ const takeField = (field) => {
3285
+ if (hostFields.has(field)) return false;
3286
+ hostFields.add(field);
3287
+ return true;
3288
+ };
3289
+ for (const f of seg.filters ?? []) {
3290
+ switch (f.kind) {
3291
+ case "hasTag":
3292
+ if (hostFilter.hasTag === void 0) {
3293
+ hostFilter.hasTag = f.tag;
3294
+ continue;
3295
+ }
3296
+ break;
3297
+ case "fieldEquals":
3298
+ if (!hostFilter.fieldEquals && takeField(f.field)) {
3299
+ hostFilter.fieldEquals = { field: f.field, value: f.value };
3300
+ continue;
3301
+ }
3302
+ break;
3303
+ case "fieldIn":
3304
+ if (!hostFilter.fieldIn && takeField(f.field)) {
3305
+ hostFilter.fieldIn = { field: f.field, values: f.values };
3306
+ continue;
3307
+ }
3308
+ break;
3309
+ case "fieldExists":
3310
+ if (!hostFilter.fieldExists && takeField(f.field)) {
3311
+ hostFilter.fieldExists = f.field;
3312
+ continue;
3313
+ }
3314
+ break;
3315
+ }
3316
+ postFilters.push(f);
3317
+ }
3318
+ return { hostFilter, postFilters };
3319
+ }
3320
+ async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
3321
+ if (filters.length === 0 || contacts.length === 0) return contacts;
3322
+ const externalIds = contacts.map((c) => c.externalId);
3323
+ const leaves = /* @__PURE__ */ new Map();
3324
+ collectLookups(filters, leaves);
3325
+ const cache = /* @__PURE__ */ new Map();
3326
+ await Promise.all(
3327
+ [...leaves].map(async ([key, f]) => {
3328
+ cache.set(key, await lookup(f, externalIds, ctx, now));
3329
+ })
3330
+ );
3331
+ return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
3332
+ }
3333
+ function lookupKey(f) {
3334
+ switch (f.kind) {
3335
+ case "subscriptionStatus":
3336
+ return `sub:${f.equals}`;
3337
+ case "firedEvent":
3338
+ case "notFiredEvent":
3339
+ return `evt:${f.withinDays ?? ""}:${f.eventName}`;
3340
+ case "opened":
3341
+ case "notOpened":
3342
+ return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
3343
+ case "subscribedAfter":
3344
+ return `subAfter:${toDate(f.date).getTime()}`;
3345
+ case "subscribedBefore":
3346
+ return `subBefore:${toDate(f.date).getTime()}`;
3347
+ default:
3348
+ return null;
3349
+ }
3350
+ }
3351
+ function collectLookups(filters, out) {
3352
+ for (const f of filters) {
3353
+ if (f.kind === "any") collectLookups(f.filters, out);
3354
+ else if (f.kind === "not") collectLookups([f.filter], out);
3355
+ else {
3356
+ const key = lookupKey(f);
3357
+ if (key && !out.has(key)) out.set(key, f);
3358
+ }
3359
+ }
3360
+ }
3361
+ async function lookup(f, externalIds, ctx, now) {
3362
+ const c = ctx.collections;
3363
+ const inPage = { $in: externalIds };
3364
+ const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
3365
+ let ids;
3366
+ switch (f.kind) {
3367
+ case "subscriptionStatus":
3368
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
3369
+ break;
3370
+ case "firedEvent":
3371
+ case "notFiredEvent": {
3372
+ const cutoff = since(f.withinDays);
3373
+ ids = await c.events.distinct("externalId", {
3374
+ externalId: inPage,
3375
+ name: f.eventName,
3376
+ ...cutoff ? { occurredAt: { $gt: cutoff } } : {}
3377
+ });
3378
+ break;
3379
+ }
3380
+ case "opened":
3381
+ case "notOpened": {
3382
+ const cutoff = since(f.withinDays);
3383
+ ids = await c.sends.distinct("externalId", {
3384
+ externalId: inPage,
3385
+ openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
3386
+ ...f.templateSlug ? { templateSlug: f.templateSlug } : {}
3387
+ });
3388
+ break;
3389
+ }
3390
+ case "subscribedAfter":
3391
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
3392
+ break;
3393
+ case "subscribedBefore":
3394
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
3395
+ break;
3396
+ default:
3397
+ ids = [];
3398
+ }
3399
+ return new Set(ids.map(String));
3400
+ }
3401
+ function matches(c, f, cache) {
3402
+ switch (f.kind) {
3403
+ case "subscriptionStatus":
3404
+ case "firedEvent":
3405
+ case "opened":
3406
+ case "subscribedAfter":
3407
+ case "subscribedBefore":
3408
+ return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
3409
+ case "notFiredEvent":
3410
+ case "notOpened":
3411
+ return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
3412
+ case "hasTag":
3413
+ return c.tags.includes(f.tag);
3414
+ case "notHasTag":
3415
+ return !c.tags.includes(f.tag);
3416
+ case "fieldEquals":
3417
+ return valuesEqual(fieldValue(c, f.field), f.value);
3418
+ case "fieldIn": {
3419
+ const v = fieldValue(c, f.field);
3420
+ return f.values.some((x) => valuesEqual(v, x));
3421
+ }
3422
+ case "fieldExists":
3423
+ return fieldValue(c, f.field) !== void 0;
3424
+ case "any":
3425
+ return f.filters.some((sub) => matches(c, sub, cache));
3426
+ case "not":
3427
+ return !matches(c, f.filter, cache);
3428
+ default:
3429
+ throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
3430
+ }
3431
+ }
3432
+ function fieldValue(c, path3) {
3433
+ let cur = c.fields;
3434
+ for (const part of path3.split(".")) {
3435
+ if (cur === null || typeof cur !== "object") return void 0;
3436
+ cur = cur[part];
3437
+ }
3438
+ return cur;
3439
+ }
3440
+ function valuesEqual(a, b) {
3441
+ if (a instanceof Date || b instanceof Date) {
3442
+ const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
3443
+ const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
3444
+ return !Number.isNaN(ta) && ta === tb;
3445
+ }
3446
+ if (a && typeof a === "object" && typeof a.toHexString === "function") {
3447
+ return String(a) === String(b);
3448
+ }
3449
+ return a === b;
3450
+ }
3451
+ function toDate(d) {
3452
+ return d instanceof Date ? d : new Date(d);
3453
+ }
3454
+
3455
+ // src/server/runner/broadcasts.ts
2890
3456
  var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
2891
3457
  async function processScheduledBroadcasts(ctx) {
2892
3458
  const now = /* @__PURE__ */ new Date();
@@ -2894,7 +3460,7 @@ async function processScheduledBroadcasts(ctx) {
2894
3460
  for (const b of due) {
2895
3461
  const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
2896
3462
  { _id: b._id, status: "scheduled" },
2897
- { $set: { status: "sending", startedAt: now, updatedAt: now } },
3463
+ { $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
2898
3464
  { returnDocument: "after" }
2899
3465
  );
2900
3466
  if (!claimed) continue;
@@ -2902,8 +3468,14 @@ async function processScheduledBroadcasts(ctx) {
2902
3468
  }
2903
3469
  }
2904
3470
  async function startBroadcastDispatch(broadcast, ctx) {
3471
+ const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
3472
+ { _id: broadcast._id },
3473
+ { $inc: { dispatchGeneration: 1 } },
3474
+ { returnDocument: "after" }
3475
+ );
3476
+ const current = bumped ?? broadcast;
2905
3477
  if (ctx.config.queue.driver === "noop") {
2906
- await runBroadcastDispatch(broadcast, ctx);
3478
+ await runBroadcastDispatch(current, ctx);
2907
3479
  return;
2908
3480
  }
2909
3481
  await ctx.queues.advance.add(
@@ -2912,7 +3484,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
2912
3484
  {
2913
3485
  attempts: 3,
2914
3486
  backoff: { type: "exponential", delay: 6e4 },
2915
- jobId: `broadcast-dispatch:${broadcast._id}`
3487
+ jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
2916
3488
  }
2917
3489
  );
2918
3490
  }
@@ -2925,10 +3497,11 @@ async function resumeStalledBroadcasts(ctx) {
2925
3497
  const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
2926
3498
  const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
2927
3499
  for (const b of stalled) {
2928
- await ctx.collections.broadcasts.updateOne(
2929
- { _id: b._id },
2930
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
3500
+ const reset = await ctx.collections.broadcasts.updateOne(
3501
+ { _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
3502
+ { $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
2931
3503
  );
3504
+ if (reset.modifiedCount === 0) continue;
2932
3505
  await startBroadcastDispatch(b, ctx);
2933
3506
  }
2934
3507
  }
@@ -2938,166 +3511,196 @@ async function runBroadcastDispatch(broadcast, ctx) {
2938
3511
  } catch (err) {
2939
3512
  console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
2940
3513
  await ctx.collections.broadcasts.updateOne(
2941
- { _id: broadcast._id },
2942
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
3514
+ { _id: broadcast._id, status: "sending" },
3515
+ {
3516
+ $set: {
3517
+ status: "failed",
3518
+ failureReason: `dispatch error: ${String(err?.message ?? err)}`,
3519
+ updatedAt: /* @__PURE__ */ new Date(),
3520
+ dispatchLeaseId: null
3521
+ }
3522
+ }
2943
3523
  );
2944
3524
  }
2945
3525
  }
3526
+ function broadcastDedupeKey(broadcastId, externalId) {
3527
+ return `broadcast:${broadcastId}:${externalId}`;
3528
+ }
3529
+ var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3530
+ function isSendableEmail(email) {
3531
+ return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
3532
+ }
3533
+ async function* eligibleRecipientPages(broadcast, kind, ctx) {
3534
+ const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
3535
+ const sort = broadcast.order ?? void 0;
3536
+ if (sort && !ctx.adapter.supportsSort) {
3537
+ throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
3538
+ }
3539
+ let cursor;
3540
+ for (; ; ) {
3541
+ const page = await ctx.adapter.query(hostFilter, {
3542
+ limit: ctx.config.broadcastEnqueueBatchSize,
3543
+ cursor,
3544
+ ...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
3545
+ });
3546
+ if (page.contacts.length === 0) break;
3547
+ const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
3548
+ const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
3549
+ yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
3550
+ if (!page.nextCursor) break;
3551
+ cursor = page.nextCursor;
3552
+ }
3553
+ }
3554
+ async function alreadyDispatched(ctx, broadcastId, contacts) {
3555
+ if (contacts.length === 0) return /* @__PURE__ */ new Set();
3556
+ const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
3557
+ const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
3558
+ return new Set(found.map(String));
3559
+ }
3560
+ async function countBroadcastRecipients(broadcast, kind, ctx) {
3561
+ const t0 = Date.now();
3562
+ const { hostFilter } = planSegment(broadcast.segmentDefinition);
3563
+ const [hostMatched, sendsSoFar] = await Promise.all([
3564
+ ctx.adapter.count(hostFilter),
3565
+ broadcast._id ? ctx.collections.sends.countDocuments({ broadcastId: broadcast._id }) : Promise.resolve(0)
3566
+ ]);
3567
+ let eligible = 0;
3568
+ let alreadySent = 0;
3569
+ for await (const page of eligibleRecipientPages(broadcast, kind, ctx)) {
3570
+ eligible += page.length;
3571
+ if (broadcast._id) alreadySent += (await alreadyDispatched(ctx, broadcast._id, page)).size;
3572
+ }
3573
+ const cap = typeof broadcast.recipientCap === "number" ? broadcast.recipientCap : null;
3574
+ const uncapped = eligible - alreadySent;
3575
+ const recipientCount = cap === null ? uncapped : Math.max(0, Math.min(uncapped, cap - sendsSoFar));
3576
+ return {
3577
+ hostMatched,
3578
+ eligible,
3579
+ alreadySent,
3580
+ sendsSoFar,
3581
+ recipientCap: cap,
3582
+ uncappedRecipientCount: uncapped,
3583
+ recipientCount,
3584
+ computedMs: Date.now() - t0
3585
+ };
3586
+ }
2946
3587
  async function dispatchBroadcast(broadcast, ctx) {
2947
3588
  const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
2948
- if (!template) {
3589
+ const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
3590
+ if (failure || !template) {
2949
3591
  await ctx.collections.broadcasts.updateOne(
2950
- { _id: broadcast._id },
2951
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
3592
+ { _id: broadcast._id, status: "sending" },
3593
+ { $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
2952
3594
  );
2953
3595
  return;
2954
3596
  }
2955
- const hostFilter = toAdapterFilter(broadcast.segmentDefinition);
2956
- const postFilters = broadcast.segmentDefinition.filters.filter((f) => isMailerSide(f));
2957
- let cursor = void 0;
2958
- let total = 0;
2959
- const batchSize = ctx.config.broadcastEnqueueBatchSize;
3597
+ const leaseId = new ObjectId().toHexString();
3598
+ const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
3599
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
3600
+ {
3601
+ _id: broadcast._id,
3602
+ status: "sending",
3603
+ $or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
3604
+ },
3605
+ { $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
3606
+ { returnDocument: "after" }
3607
+ );
3608
+ if (!b) return;
3609
+ const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
3610
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
3611
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
3612
+ )).matchedCount === 1;
2960
3613
  const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
2961
- const respectTimezone = broadcast.respectRecipientTimezone === true;
2962
- const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
2963
- for (; ; ) {
2964
- await ctx.collections.broadcasts.updateOne(
2965
- { _id: broadcast._id },
2966
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
2967
- );
2968
- const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
2969
- if (page.contacts.length === 0) break;
2970
- const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
2971
- if (eligible.length > 0) {
3614
+ const respectTimezone = b.respectRecipientTimezone === true;
3615
+ const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
3616
+ const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
3617
+ let capReached = false;
3618
+ try {
3619
+ for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
3620
+ if (!await holdsLease()) return;
3621
+ const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
3622
+ if (bucket?.status === "tripped") {
3623
+ await pauseBroadcast(ctx, b._id, {
3624
+ code: "circuit_breaker",
3625
+ message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
3626
+ at: /* @__PURE__ */ new Date(),
3627
+ details: { bucket: bucket._id }
3628
+ });
3629
+ return;
3630
+ }
3631
+ const seen = await alreadyDispatched(ctx, b._id, eligible);
3632
+ let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
3633
+ if (fresh.length === 0) continue;
3634
+ if (cap !== null) {
3635
+ const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
3636
+ if (room <= 0) {
3637
+ capReached = true;
3638
+ break;
3639
+ }
3640
+ if (fresh.length > room) {
3641
+ fresh = fresh.slice(0, room);
3642
+ capReached = true;
3643
+ }
3644
+ }
2972
3645
  while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
2973
- await ctx.collections.broadcasts.updateOne(
2974
- { _id: broadcast._id },
2975
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
2976
- );
3646
+ if (!await holdsLease()) return;
2977
3647
  await sleep(2e3);
2978
3648
  }
2979
- const sendDocs = await Promise.all(
2980
- eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
2981
- );
2982
3649
  const inserted = [];
2983
- for (const { doc, delayMs } of sendDocs) {
2984
- if (!doc) continue;
3650
+ for (const contact of fresh) {
3651
+ const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
2985
3652
  try {
2986
3653
  await ctx.collections.sends.insertOne(doc);
2987
3654
  inserted.push({ sendId: doc._id, delayMs });
2988
3655
  } catch (err) {
2989
- if (err?.code !== 11e3) throw err;
2990
- }
2991
- }
2992
- if (inserted.length > 0) {
2993
- await Promise.all(
2994
- inserted.map(
2995
- ({ sendId, delayMs }) => ctx.queues.send.add(
2996
- "send",
2997
- { sendId: String(sendId) },
2998
- {
2999
- attempts: ctx.config.sendRetryAttempts,
3000
- backoff: { type: "exponential", delay: 6e4 },
3001
- ...delayMs > 0 ? { delay: delayMs } : {}
3002
- }
3003
- )
3004
- )
3005
- );
3006
- }
3007
- total += inserted.length;
3008
- }
3009
- if (!page.nextCursor) break;
3010
- cursor = page.nextCursor;
3011
- }
3012
- await ctx.collections.broadcasts.updateOne(
3013
- { _id: broadcast._id },
3014
- {
3015
- $set: {
3016
- status: "sent",
3017
- completedAt: /* @__PURE__ */ new Date(),
3018
- recipientCount: total,
3019
- updatedAt: /* @__PURE__ */ new Date()
3020
- }
3021
- }
3022
- );
3023
- }
3024
- function toAdapterFilter(seg) {
3025
- const out = {};
3026
- for (const f of seg.filters) {
3027
- switch (f.kind) {
3028
- case "hasTag":
3029
- out.hasTag = f.tag;
3030
- break;
3031
- case "fieldEquals":
3032
- out.fieldEquals = { field: f.field, value: f.value };
3033
- break;
3034
- case "fieldIn":
3035
- out.fieldIn = { field: f.field, values: f.values };
3036
- break;
3037
- case "fieldExists":
3038
- out.fieldExists = f.field;
3039
- break;
3040
- }
3041
- }
3042
- return out;
3043
- }
3044
- function isMailerSide(f) {
3045
- 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";
3046
- }
3047
- async function applyPostFilters(contacts, filters, ctx) {
3048
- if (filters.length === 0) return contacts;
3049
- const externalIds = contacts.map((c) => c.externalId);
3050
- const cache = {};
3051
- for (const f of filters) {
3052
- if (f.kind === "subscriptionStatus") {
3053
- const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
3054
- cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
3055
- }
3056
- if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
3057
- const query = { externalId: { $in: externalIds }, name: f.eventName };
3058
- if (f.withinDays) {
3059
- query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
3060
- }
3061
- const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
3062
- cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
3063
- }
3064
- }
3065
- return contacts.filter((c) => filters.every((f) => filterMatches(c, f, cache)));
3066
- }
3067
- function filterMatches(c, f, cache) {
3068
- switch (f.kind) {
3069
- case "subscriptionStatus":
3070
- return cache[`sub:${f.equals}`]?.has(c.externalId) ?? false;
3071
- case "firedEvent":
3072
- return cache[`evt:${f.eventName}`]?.has(c.externalId) ?? false;
3073
- case "notFiredEvent":
3074
- return !cache[`evt:${f.eventName}`]?.has(c.externalId);
3075
- case "notHasTag":
3076
- return !c.tags.includes(f.tag);
3077
- case "opened":
3078
- case "notOpened":
3079
- return true;
3080
- case "subscribedAfter":
3081
- case "subscribedBefore":
3082
- return true;
3083
- // V2
3084
- case "any":
3085
- return f.filters.some((sub) => filterMatches(c, sub, cache));
3086
- case "not":
3087
- return !filterMatches(c, f.filter, cache);
3088
- default:
3089
- return true;
3656
+ if (err?.code !== 11e3) throw err;
3657
+ }
3658
+ }
3659
+ await Promise.all(
3660
+ inserted.map(
3661
+ ({ sendId, delayMs }) => ctx.queues.send.add(
3662
+ "send",
3663
+ { sendId: String(sendId) },
3664
+ {
3665
+ attempts: ctx.config.sendRetryAttempts,
3666
+ backoff: { type: "exponential", delay: 6e4 },
3667
+ ...delayMs > 0 ? { delay: delayMs } : {}
3668
+ }
3669
+ )
3670
+ )
3671
+ );
3672
+ if (capReached) break;
3673
+ }
3674
+ const now = /* @__PURE__ */ new Date();
3675
+ const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
3676
+ const set2 = capReached ? {
3677
+ status: "paused",
3678
+ pausedAt: now,
3679
+ pauseReason: {
3680
+ code: "cap_reached",
3681
+ message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
3682
+ at: now,
3683
+ details: { recipientCap: cap, sendsSoFar: total }
3684
+ }
3685
+ } : { status: "sent", completedAt: now };
3686
+ await ctx.collections.broadcasts.updateOne(
3687
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
3688
+ { $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
3689
+ );
3690
+ } finally {
3691
+ await ctx.collections.broadcasts.updateOne(
3692
+ { _id: b._id, dispatchLeaseId: leaseId },
3693
+ { $set: { dispatchLeaseId: null } }
3694
+ );
3090
3695
  }
3091
3696
  }
3092
- async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
3093
- const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
3094
- if (supp.suppressed) return { doc: null, delayMs: 0 };
3697
+ function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
3095
3698
  const sendId = new ObjectId();
3096
- const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
3097
- let delayMs = Math.max(0, scheduledMs - Date.now());
3699
+ const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
3700
+ const now = Date.now();
3701
+ let delayMs = Math.max(0, scheduledMs - now);
3098
3702
  if (respectTimezone && contact.timezone) {
3099
- const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
3100
- delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
3703
+ delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
3101
3704
  }
3102
3705
  const doc = {
3103
3706
  _id: sendId,
@@ -3132,16 +3735,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
3132
3735
  queuedAt: /* @__PURE__ */ new Date(),
3133
3736
  updatedAt: /* @__PURE__ */ new Date(),
3134
3737
  sentAt: null,
3135
- deliveredAt: null
3738
+ deliveredAt: null,
3739
+ notBefore: new Date(Date.now() + delayMs)
3136
3740
  };
3137
3741
  return { doc, delayMs };
3138
3742
  }
3743
+ var DAY_MS = 24 * 60 * 60 * 1e3;
3744
+ var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
3745
+ function recipientSlotMs(scheduledMs, timezone, nowMs) {
3746
+ let anchor = scheduledMs;
3747
+ let target = anchor + perRecipientOffsetMs(anchor, timezone);
3748
+ const earliest = nowMs - TZ_SLOT_GRACE_MS;
3749
+ if (target < earliest) {
3750
+ anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
3751
+ target = anchor + perRecipientOffsetMs(anchor, timezone);
3752
+ if (target < earliest) target += DAY_MS;
3753
+ }
3754
+ return target;
3755
+ }
3139
3756
  function perRecipientOffsetMs(scheduledMs, timezone) {
3140
- const DAY_MS2 = 24 * 60 * 60 * 1e3;
3141
3757
  try {
3142
3758
  const scheduled = new Date(scheduledMs);
3143
- const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
3144
- const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hour12: false });
3759
+ const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
3760
+ const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
3145
3761
  const parse = (s) => {
3146
3762
  const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
3147
3763
  if (!m) return 0;
@@ -3150,7 +3766,7 @@ function perRecipientOffsetMs(scheduledMs, timezone) {
3150
3766
  const utcMs = parse(utc);
3151
3767
  const localMs = parse(local);
3152
3768
  const offsetMs = utcMs - localMs;
3153
- return (offsetMs % DAY_MS2 + DAY_MS2) % DAY_MS2;
3769
+ return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
3154
3770
  } catch {
3155
3771
  return 0;
3156
3772
  }
@@ -3262,9 +3878,9 @@ async function runDnsblChecks(ctx, opts = {}) {
3262
3878
  let listedCount = 0;
3263
3879
  async function processPair(p) {
3264
3880
  const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
3265
- const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3266
- if (lookup.transient) return;
3267
- if (lookup.result === "listed") listedCount++;
3881
+ const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3882
+ if (lookup2.transient) return;
3883
+ if (lookup2.result === "listed") listedCount++;
3268
3884
  await ctx.collections.dnsblChecks.updateOne(
3269
3885
  { target: p.target, list: p.list.host },
3270
3886
  {
@@ -3273,9 +3889,9 @@ async function runDnsblChecks(ctx, opts = {}) {
3273
3889
  targetKind: p.targetKind,
3274
3890
  list: p.list.host,
3275
3891
  listLabel: p.list.label,
3276
- result: lookup.result,
3277
- returnCodes: lookup.returnCodes,
3278
- errorMessage: lookup.errorMessage,
3892
+ result: lookup2.result,
3893
+ returnCodes: lookup2.returnCodes,
3894
+ errorMessage: lookup2.errorMessage,
3279
3895
  runAt: /* @__PURE__ */ new Date()
3280
3896
  }
3281
3897
  },
@@ -3919,17 +4535,17 @@ function parseDmarcReport(xml) {
3919
4535
  const day = new Date(midMs).toISOString().slice(0, 10);
3920
4536
  for (const rec of records) {
3921
4537
  const row = rec?.row ?? {};
3922
- const count = Number(row.count ?? 0) || 0;
3923
- totalMessages += count;
4538
+ const count2 = Number(row.count ?? 0) || 0;
4539
+ totalMessages += count2;
3924
4540
  const evald = row.policy_evaluated ?? {};
3925
4541
  const dkim = evald.dkim ?? "none";
3926
4542
  const spf = evald.spf ?? "none";
3927
4543
  const aligned = dkim === "pass" || spf === "pass";
3928
4544
  if (aligned) {
3929
- passCount += count;
4545
+ passCount += count2;
3930
4546
  continue;
3931
4547
  }
3932
- failCount += count;
4548
+ failCount += count2;
3933
4549
  const sourceIp = String(row.source_ip ?? "").trim();
3934
4550
  if (!sourceIp) continue;
3935
4551
  const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
@@ -3938,7 +4554,7 @@ function parseDmarcReport(xml) {
3938
4554
  reportId,
3939
4555
  domain,
3940
4556
  sourceIp,
3941
- count,
4557
+ count: count2,
3942
4558
  headerFrom,
3943
4559
  dkimResult: dkim,
3944
4560
  spfResult: spf,
@@ -4083,11 +4699,11 @@ function suggestPolicyProgression(input) {
4083
4699
  return null;
4084
4700
  }
4085
4701
  if (currentPolicy === "quarantine") {
4086
- const pct = currentPct ?? 100;
4702
+ const pct2 = currentPct ?? 100;
4087
4703
  if (alignmentRate < 0.995) return null;
4088
- if (pct < 25) return { policy: "quarantine", pct: 25, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct} to 25.` };
4089
- if (pct < 50) return { policy: "quarantine", pct: 50, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct} to 50.` };
4090
- if (pct < 100) return { policy: "quarantine", pct: 100, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct} to 100.` };
4704
+ if (pct2 < 25) return { policy: "quarantine", pct: 25, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct2} to 25.` };
4705
+ if (pct2 < 50) return { policy: "quarantine", pct: 50, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct2} to 50.` };
4706
+ if (pct2 < 100) return { policy: "quarantine", pct: 100, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct2} to 100.` };
4091
4707
  if (alignmentRate >= 0.999) {
4092
4708
  return { policy: "reject", pct: 100, reason: `Alignment at ${(alignmentRate * 100).toFixed(3)}% with policy=quarantine pct=100 \u2014 safe to move to p=reject.` };
4093
4709
  }
@@ -4398,6 +5014,9 @@ async function runTick(ctx) {
4398
5014
  await resumeStalledBroadcasts(ctx).catch((err) => {
4399
5015
  console.error("mailery: stalled-broadcast resume failed", err);
4400
5016
  });
5017
+ await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
5018
+ console.error("mailery: broadcast stop-rule sweep failed", err);
5019
+ });
4401
5020
  await evaluateHealth(ctx).catch((err) => {
4402
5021
  console.error("mailery: health evaluation failed", err);
4403
5022
  });
@@ -6105,7 +6724,7 @@ function humanDuration(ms) {
6105
6724
  }
6106
6725
 
6107
6726
  // src/server/runner/hygiene.ts
6108
- var DAY_MS = 864e5;
6727
+ var DAY_MS2 = 864e5;
6109
6728
  var HYGIENE_BUCKETS = [
6110
6729
  { label: "Engaged (last 30 days)", maxDays: 30 },
6111
6730
  { label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
@@ -6118,7 +6737,7 @@ async function computeListHygiene(ctx, opts = {}) {
6118
6737
  const now = opts.now ?? Date.now();
6119
6738
  const sunsetDays = opts.sunsetThresholdDays ?? 180;
6120
6739
  const recentDays = opts.recentWindowDays ?? 90;
6121
- const recentCutoff = new Date(now - recentDays * DAY_MS);
6740
+ const recentCutoff = new Date(now - recentDays * DAY_MS2);
6122
6741
  const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
6123
6742
  const subscribedIds = /* @__PURE__ */ new Set();
6124
6743
  {
@@ -6191,11 +6810,11 @@ async function computeListHygiene(ctx, opts = {}) {
6191
6810
  if (lastEngaged == null) {
6192
6811
  const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
6193
6812
  buckets[5].count++;
6194
- if (firstSent != null && now - firstSent > sunsetDays * DAY_MS) {
6813
+ if (firstSent != null && now - firstSent > sunsetDays * DAY_MS2) {
6195
6814
  isSunset = true;
6196
6815
  }
6197
6816
  } else {
6198
- const daysSince = (now - lastEngaged) / DAY_MS;
6817
+ const daysSince = (now - lastEngaged) / DAY_MS2;
6199
6818
  if (daysSince <= 30) buckets[0].count++;
6200
6819
  else if (daysSince <= 60) buckets[1].count++;
6201
6820
  else if (daysSince <= 90) buckets[2].count++;
@@ -6394,6 +7013,462 @@ async function evaluateMailTesterGate(ctx, input) {
6394
7013
  }
6395
7014
  return { allowed: true, reason: null, score: cached, code: null };
6396
7015
  }
7016
+ var BroadcastOperationError = class extends Error {
7017
+ constructor(code, message, status = 400, details) {
7018
+ super(message);
7019
+ this.code = code;
7020
+ this.status = status;
7021
+ this.details = details;
7022
+ this.name = "BroadcastOperationError";
7023
+ }
7024
+ code;
7025
+ status;
7026
+ details;
7027
+ };
7028
+ var DEFAULT_SEGMENT = {
7029
+ filters: [{ kind: "subscriptionStatus", equals: "subscribed" }]
7030
+ };
7031
+ var MAX_RECIPIENT_CAP = 1e7;
7032
+ var ORDER_FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
7033
+ var recipientCapSchema = z.number().int().positive().max(MAX_RECIPIENT_CAP).nullable();
7034
+ var orderSchema = z.object({
7035
+ field: z.string().max(128).regex(ORDER_FIELD_RE, "must be a plain field path, e.g. updatedAt"),
7036
+ direction: z.enum(["asc", "desc"])
7037
+ }).nullable();
7038
+ var pctSchema = z.number().min(0).max(100);
7039
+ var stopRulesSchema = z.object({
7040
+ enabled: z.boolean().optional(),
7041
+ hardBounceRatePct: pctSchema.optional(),
7042
+ complaintRatePct: pctSchema.optional(),
7043
+ unsubscribeRatePct: pctSchema.optional(),
7044
+ minSample: z.number().int().min(1).max(1e6).optional()
7045
+ }).strict().nullable();
7046
+ var agentCreateBroadcastSchema = z.object({
7047
+ slug: slugSchema,
7048
+ name: z.string().min(1).max(200),
7049
+ templateSlug: slugSchema,
7050
+ segmentDefinition: z.object({ filters: z.array(z.any()) }).optional(),
7051
+ respectRecipientTimezone: z.boolean().optional(),
7052
+ recipientCap: recipientCapSchema.optional(),
7053
+ order: orderSchema.optional(),
7054
+ stopRules: stopRulesSchema.optional()
7055
+ });
7056
+ var agentPatchBroadcastSchema = z.object({
7057
+ name: z.string().min(1).max(200).optional(),
7058
+ templateSlug: slugSchema.optional(),
7059
+ segmentDefinition: z.object({ filters: z.array(z.any()) }).optional(),
7060
+ respectRecipientTimezone: z.boolean().optional(),
7061
+ recipientCap: recipientCapSchema.optional(),
7062
+ order: orderSchema.optional(),
7063
+ stopRules: stopRulesSchema.optional()
7064
+ });
7065
+ var agentResumeBroadcastSchema = z.object({
7066
+ recipientCap: recipientCapSchema.optional(),
7067
+ stopRules: stopRulesSchema.optional(),
7068
+ confirmedCount: z.number().int().nonnegative()
7069
+ });
7070
+ var agentPauseBroadcastSchema = z.object({
7071
+ reason: z.string().max(500).optional()
7072
+ });
7073
+ function checkWaveSettings(mailer, input) {
7074
+ if (input.stopRules !== void 0 && !stopRulesSchema.safeParse(input.stopRules).success) {
7075
+ throw new BroadcastOperationError(
7076
+ "invalid_stop_rules",
7077
+ "stopRules must be {enabled?, hardBounceRatePct?, complaintRatePct?, unsubscribeRatePct?, minSample?} (percentages 0-100), or null"
7078
+ );
7079
+ }
7080
+ if (input.recipientCap !== void 0 && !recipientCapSchema.safeParse(input.recipientCap).success) {
7081
+ throw new BroadcastOperationError("invalid_recipient_cap", `recipientCap must be a positive integer up to ${MAX_RECIPIENT_CAP}, or null`);
7082
+ }
7083
+ if (input.order !== void 0) {
7084
+ if (!orderSchema.safeParse(input.order).success) {
7085
+ throw new BroadcastOperationError("invalid_order", 'order must be {field: "<host field path>", direction: "asc" | "desc"}, or null');
7086
+ }
7087
+ if (input.order !== null && !mailer.adapter.supportsSort) {
7088
+ throw new BroadcastOperationError(
7089
+ "adapter_cannot_sort",
7090
+ "the contact adapter does not declare supportsSort, so a broadcast cannot be sent in an order; omit order to send in the adapter's own order",
7091
+ 422
7092
+ );
7093
+ }
7094
+ }
7095
+ }
7096
+ var agentScheduleBroadcastSchema = z.object({
7097
+ scheduledAt: z.string().min(1),
7098
+ confirmedCount: z.number().int().nonnegative(),
7099
+ respectRecipientTimezone: z.boolean().optional()
7100
+ });
7101
+ function parseSegment(seg, strict) {
7102
+ const r = segmentDefinitionSchema(strict).safeParse(seg);
7103
+ if (!r.success) {
7104
+ throw new BroadcastOperationError(
7105
+ "invalid_segment",
7106
+ `segmentDefinition: ${r.error.issues.map((i) => `${i.path.join(".") || "filters"}: ${i.message}`).join("; ")}`,
7107
+ 400
7108
+ );
7109
+ }
7110
+ return r.data;
7111
+ }
7112
+ function assertSubscribedSegment(seg) {
7113
+ const filters = Array.isArray(seg?.filters) ? seg.filters : [];
7114
+ const ok = filters.some((f) => f && f.kind === "subscriptionStatus" && f.equals === "subscribed");
7115
+ if (!ok) {
7116
+ throw new BroadcastOperationError(
7117
+ "segment_requires_subscribed",
7118
+ 'the agent API only schedules broadcasts to subscribed contacts: add {"kind": "subscriptionStatus", "equals": "subscribed"} to segmentDefinition.filters (top level)',
7119
+ 422
7120
+ );
7121
+ }
7122
+ }
7123
+ async function loadBroadcast(mailer, slug) {
7124
+ const b = await mailer.collections.broadcasts.findOne({ slug });
7125
+ if (!b) throw new BroadcastOperationError("not_found", `no broadcast with slug "${slug}"`, 404);
7126
+ return b;
7127
+ }
7128
+ async function loadBroadcastTemplate(mailer, b) {
7129
+ const tpl = await mailer.collections.templates.findOne({ slug: b.templateSlug });
7130
+ if (!tpl) {
7131
+ throw new BroadcastOperationError("template_not_found", `template "${b.templateSlug}" does not exist`, 409);
7132
+ }
7133
+ return tpl;
7134
+ }
7135
+ async function countRecipients(mailer, b, segment, overrides = {}) {
7136
+ const tpl = await loadBroadcastTemplate(mailer, b);
7137
+ const count2 = await countBroadcastRecipients(
7138
+ {
7139
+ _id: b._id,
7140
+ segmentDefinition: segment ?? b.segmentDefinition,
7141
+ order: b.order ?? null,
7142
+ recipientCap: overrides.recipientCap !== void 0 ? overrides.recipientCap : b.recipientCap ?? null
7143
+ },
7144
+ tpl.kind,
7145
+ mailer.getRunnerContext()
7146
+ );
7147
+ const heldSends = b._id ? await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: "held" }) : 0;
7148
+ return { ...count2, heldSends, templateKind: tpl.kind };
7149
+ }
7150
+ function stopRuleStatus(mailer, b, stats) {
7151
+ return evaluateStopRules(stats, effectiveStopRules(mailer.config, b));
7152
+ }
7153
+ async function pauseBroadcastByOperator(mailer, slug, input, actor) {
7154
+ const b = await loadBroadcast(mailer, slug);
7155
+ if (b.status !== "sending" && b.status !== "sent") {
7156
+ throw new BroadcastOperationError("not_pausable", `broadcast is ${b.status}; only a sending or sent broadcast can be paused (cancel a draft or scheduled one)`, 409);
7157
+ }
7158
+ const out = await pauseBroadcast(
7159
+ mailer.getRunnerContext(),
7160
+ b._id,
7161
+ { code: "manual", message: input.reason?.trim() || `paused by ${actor}`, at: /* @__PURE__ */ new Date(), details: { actor } },
7162
+ actor
7163
+ );
7164
+ if (!out.paused) throw new BroadcastOperationError("not_pausable", "broadcast changed state while being paused", 409);
7165
+ return { broadcast: await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? b, heldSends: out.heldSends };
7166
+ }
7167
+ async function createBroadcast(mailer, input, actor, opts = {}) {
7168
+ const { slug, name, templateSlug } = input;
7169
+ if (!slug || !name || !templateSlug) {
7170
+ throw new BroadcastOperationError("validation_failed", "slug, name, templateSlug required");
7171
+ }
7172
+ const segmentDefinition = input.segmentDefinition ? parseSegment(input.segmentDefinition, !!opts.strictSegment) : DEFAULT_SEGMENT;
7173
+ if (opts.requireSubscribed) assertSubscribedSegment(segmentDefinition);
7174
+ checkWaveSettings(mailer, input);
7175
+ await assertBroadcastTemplate(mailer, templateSlug, { mustExist: false });
7176
+ const now = /* @__PURE__ */ new Date();
7177
+ const doc = {
7178
+ slug,
7179
+ name,
7180
+ templateSlug,
7181
+ segmentDefinition,
7182
+ status: "draft",
7183
+ scheduledAt: null,
7184
+ startedAt: null,
7185
+ completedAt: null,
7186
+ confirmationRequired: true,
7187
+ confirmedCount: null,
7188
+ confirmedAt: null,
7189
+ confirmedBy: null,
7190
+ recipientCount: null,
7191
+ stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
7192
+ createdAt: now,
7193
+ createdBy: actor,
7194
+ updatedAt: now
7195
+ };
7196
+ if (input.respectRecipientTimezone) doc.respectRecipientTimezone = true;
7197
+ if (input.recipientCap !== void 0) doc.recipientCap = input.recipientCap;
7198
+ if (input.order !== void 0) doc.order = input.order;
7199
+ if (input.stopRules !== void 0) doc.stopRules = input.stopRules;
7200
+ try {
7201
+ const res = await mailer.collections.broadcasts.insertOne(doc);
7202
+ doc._id = res.insertedId;
7203
+ } catch (err) {
7204
+ if (err?.code === 11e3) throw new BroadcastOperationError("slug_taken", `slug "${slug}" is taken`, 409);
7205
+ throw err;
7206
+ }
7207
+ await mailer.audit({
7208
+ actor,
7209
+ action: "broadcast.create",
7210
+ resource: { collection: "mailer_broadcasts", id: doc._id, slug }
7211
+ });
7212
+ return doc;
7213
+ }
7214
+ async function patchBroadcast(mailer, slug, patch, actor, opts = {}) {
7215
+ const b = await loadBroadcast(mailer, slug);
7216
+ if (b.status !== "draft") {
7217
+ throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be edited`, 409);
7218
+ }
7219
+ const segmentDefinition = patch.segmentDefinition ? parseSegment(patch.segmentDefinition, !!opts.strictSegment) : void 0;
7220
+ if (opts.requireSubscribed && segmentDefinition) assertSubscribedSegment(segmentDefinition);
7221
+ checkWaveSettings(mailer, patch);
7222
+ if (typeof patch.templateSlug === "string") await assertBroadcastTemplate(mailer, patch.templateSlug, { mustExist: false });
7223
+ const set2 = { updatedAt: /* @__PURE__ */ new Date() };
7224
+ if (patch.recipientCap !== void 0) set2.recipientCap = patch.recipientCap;
7225
+ if (patch.order !== void 0) set2.order = patch.order;
7226
+ if (patch.stopRules !== void 0) set2.stopRules = patch.stopRules;
7227
+ if (typeof patch.name === "string") set2.name = patch.name;
7228
+ if (typeof patch.templateSlug === "string") set2.templateSlug = patch.templateSlug;
7229
+ if (segmentDefinition) set2.segmentDefinition = segmentDefinition;
7230
+ if (typeof patch.respectRecipientTimezone === "boolean") set2.respectRecipientTimezone = patch.respectRecipientTimezone;
7231
+ const res = await mailer.collections.broadcasts.findOneAndUpdate(
7232
+ { _id: b._id, status: "draft" },
7233
+ { $set: set2 },
7234
+ { returnDocument: "after" }
7235
+ );
7236
+ if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being edited", 409);
7237
+ await mailer.audit({
7238
+ actor,
7239
+ action: "broadcast.update",
7240
+ resource: { collection: "mailer_broadcasts", id: b._id, slug },
7241
+ diffSummary: Object.keys(set2).filter((k) => k !== "updatedAt").join(", ")
7242
+ });
7243
+ return res;
7244
+ }
7245
+ async function scheduleBroadcast(mailer, slug, input, actor, opts = {}) {
7246
+ const b = await loadBroadcast(mailer, slug);
7247
+ if (b.status !== "draft") {
7248
+ throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be scheduled`, 409);
7249
+ }
7250
+ if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
7251
+ parseSegment(b.segmentDefinition, true);
7252
+ await assertBroadcastTemplate(mailer, b.templateSlug, { mustExist: true });
7253
+ if (!input.scheduledAt) throw new BroadcastOperationError("scheduledAt_required", "scheduledAt is required");
7254
+ const scheduled = new Date(input.scheduledAt);
7255
+ if (Number.isNaN(scheduled.getTime())) throw new BroadcastOperationError("bad_scheduledAt", "scheduledAt is not a date");
7256
+ if (typeof input.confirmedCount !== "number") {
7257
+ throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
7258
+ }
7259
+ const confirmedCount = input.confirmedCount;
7260
+ const threshold = mailer.config.broadcastConfirmationThreshold;
7261
+ if (opts.requireExactCount) {
7262
+ const { recipientCount } = await countRecipients(mailer, b);
7263
+ if (confirmedCount !== recipientCount) {
7264
+ throw new BroadcastOperationError(
7265
+ "count_mismatch",
7266
+ `confirmedCount ${confirmedCount} does not match the ${recipientCount} recipient(s) this broadcast would send to now; recount with POST /broadcasts/${b.slug}/count`,
7267
+ 409,
7268
+ { expected: recipientCount, confirmedCount }
7269
+ );
7270
+ }
7271
+ }
7272
+ const set2 = {
7273
+ status: "scheduled",
7274
+ scheduledAt: scheduled,
7275
+ confirmedCount,
7276
+ confirmedAt: /* @__PURE__ */ new Date(),
7277
+ confirmedBy: actor,
7278
+ updatedAt: /* @__PURE__ */ new Date()
7279
+ };
7280
+ if (input.respectRecipientTimezone) set2.respectRecipientTimezone = true;
7281
+ const res = await mailer.collections.broadcasts.findOneAndUpdate(
7282
+ { _id: b._id, status: "draft" },
7283
+ { $set: set2 },
7284
+ { returnDocument: "after" }
7285
+ );
7286
+ if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being scheduled", 409);
7287
+ await mailer.audit({
7288
+ actor,
7289
+ action: "broadcast.schedule",
7290
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7291
+ diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
7292
+ });
7293
+ return res;
7294
+ }
7295
+ async function resumeBroadcast(mailer, slug, input, actor, opts = {}) {
7296
+ const b = await loadBroadcast(mailer, slug);
7297
+ if (b.status !== "paused") {
7298
+ throw new BroadcastOperationError("not_paused", `broadcast is ${b.status}; only a paused broadcast can be resumed`, 409);
7299
+ }
7300
+ if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
7301
+ checkWaveSettings(mailer, { recipientCap: input.recipientCap, stopRules: input.stopRules });
7302
+ const cap = input.recipientCap !== void 0 ? input.recipientCap : b.recipientCap ?? null;
7303
+ const stopRules = input.stopRules === void 0 ? b.stopRules ?? null : input.stopRules === null ? null : { ...b.stopRules ?? {}, ...input.stopRules };
7304
+ if (b.pauseReason?.code === "stop_rule") {
7305
+ const evaluation = evaluateStopRules(
7306
+ await broadcastStatsFor(mailer.collections, b._id),
7307
+ effectiveStopRules(mailer.config, { stopRules })
7308
+ );
7309
+ if (evaluation.breaches.length > 0) {
7310
+ throw new BroadcastOperationError(
7311
+ "stop_rule_still_breached",
7312
+ `the stop rules still fire (${evaluation.breaches.map((x) => `${x.rule} ${x.ratePct}% > ${x.thresholdPct}%`).join(", ")}); investigate, then resume with adjusted stopRules to override`,
7313
+ 409,
7314
+ { evaluation }
7315
+ );
7316
+ }
7317
+ }
7318
+ if (b.pauseReason?.code === "circuit_breaker") {
7319
+ const tpl = await loadBroadcastTemplate(mailer, b);
7320
+ const bucket = await getBucketStatus(mailer.getRunnerContext(), tpl.fromEmail, tpl.kind);
7321
+ if (bucket?.status === "tripped") {
7322
+ throw new BroadcastOperationError(
7323
+ "circuit_breaker_tripped",
7324
+ `the ${bucket.senderDomain ?? "sender"} ${tpl.kind} circuit breaker is still tripped (${bucket.trippedReason ?? "no reason recorded"}); resume it first (POST /api/health/resume)`,
7325
+ 409,
7326
+ { bucket: bucket._id, trippedReason: bucket.trippedReason }
7327
+ );
7328
+ }
7329
+ }
7330
+ const sendsSoFar = await mailer.collections.sends.countDocuments({ broadcastId: b._id });
7331
+ if (cap !== null && cap <= sendsSoFar && b.pauseReason?.code === "cap_reached") {
7332
+ throw new BroadcastOperationError(
7333
+ "cap_not_raised",
7334
+ `${sendsSoFar} send(s) already count against recipientCap ${cap}; pass a higher recipientCap (or null for no cap) to send the next wave`,
7335
+ 409,
7336
+ { recipientCap: cap, sendsSoFar }
7337
+ );
7338
+ }
7339
+ if (cap !== null && cap < sendsSoFar) {
7340
+ throw new BroadcastOperationError("cap_below_sent", `recipientCap ${cap} is below the ${sendsSoFar} send(s) already made`, 400);
7341
+ }
7342
+ let expected = null;
7343
+ if (opts.requireExactCount) {
7344
+ if (typeof input.confirmedCount !== "number") {
7345
+ throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
7346
+ }
7347
+ const count2 = await countRecipients(mailer, b, void 0, { recipientCap: cap });
7348
+ expected = count2.recipientCount + count2.heldSends;
7349
+ if (input.confirmedCount !== expected) {
7350
+ throw new BroadcastOperationError(
7351
+ "count_mismatch",
7352
+ `confirmedCount ${input.confirmedCount} does not match the ${expected} send(s) resuming would release now (${count2.recipientCount} new + ${count2.heldSends} held)`,
7353
+ 409,
7354
+ { expected, recipientCount: count2.recipientCount, heldSends: count2.heldSends, confirmedCount: input.confirmedCount }
7355
+ );
7356
+ }
7357
+ }
7358
+ const now = /* @__PURE__ */ new Date();
7359
+ const resumed = await mailer.collections.broadcasts.findOneAndUpdate(
7360
+ { _id: b._id, status: "paused" },
7361
+ {
7362
+ $set: {
7363
+ status: "sending",
7364
+ recipientCap: cap,
7365
+ stopRules,
7366
+ pausedAt: null,
7367
+ pauseReason: null,
7368
+ dispatchLeaseId: null,
7369
+ updatedAt: now,
7370
+ ...typeof input.confirmedCount === "number" ? { confirmedCount: input.confirmedCount, confirmedAt: now, confirmedBy: actor } : {}
7371
+ }
7372
+ },
7373
+ { returnDocument: "after" }
7374
+ );
7375
+ if (!resumed) throw new BroadcastOperationError("not_paused", "broadcast left paused while being resumed", 409);
7376
+ await mailer.audit({
7377
+ actor,
7378
+ action: "broadcast.resume",
7379
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7380
+ diffSummary: `was paused (${b.pauseReason?.code ?? "unknown"}) \xB7 recipientCap ${b.recipientCap ?? "none"} \u2192 ${cap ?? "none"} \xB7 sendsSoFar=${sendsSoFar}${expected !== null ? ` \xB7 confirmedCount=${expected}` : ""}`
7381
+ });
7382
+ const ctx = mailer.getRunnerContext();
7383
+ await releaseHeldSends(ctx, b._id);
7384
+ await startBroadcastDispatch(resumed, ctx);
7385
+ return await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? resumed;
7386
+ }
7387
+ async function cancelBroadcast(mailer, slug, actor) {
7388
+ const b = await loadBroadcast(mailer, slug);
7389
+ if (b.status === "cancelled") return { broadcast: b, cancelledSends: 0 };
7390
+ const pending = await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: { $in: ["queued", "held"] } });
7391
+ if ((b.status === "sent" || b.status === "failed") && pending === 0) {
7392
+ throw new BroadcastOperationError("already_finished", `broadcast is ${b.status} with nothing left to send; there is nothing to cancel`, 409);
7393
+ }
7394
+ await mailer.collections.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
7395
+ const now = /* @__PURE__ */ new Date();
7396
+ const cancelled = await mailer.collections.sends.updateMany(
7397
+ { broadcastId: b._id, status: { $in: ["queued", "held"] } },
7398
+ { $set: { status: "cancelled", errorMessage: `cancelled: broadcast cancelled by ${actor}`, updatedAt: now } }
7399
+ );
7400
+ await mailer.audit({
7401
+ actor,
7402
+ action: "broadcast.cancel",
7403
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7404
+ diffSummary: `was ${b.status}; cancelled ${cancelled.modifiedCount} queued/held send(s)`
7405
+ });
7406
+ const after = await mailer.collections.broadcasts.findOne({ _id: b._id });
7407
+ return { broadcast: after ?? b, cancelledSends: cancelled.modifiedCount };
7408
+ }
7409
+ async function assertBroadcastTemplate(mailer, templateSlug, opts) {
7410
+ const tpl = await mailer.collections.templates.findOne({ slug: templateSlug });
7411
+ if (!tpl) {
7412
+ if (opts.mustExist) throw new BroadcastOperationError("template_not_found", `template "${templateSlug}" does not exist`, 409);
7413
+ return;
7414
+ }
7415
+ if (tpl.kind !== "marketing") {
7416
+ throw new BroadcastOperationError(
7417
+ "template_not_marketing",
7418
+ `template "${templateSlug}" is ${tpl.kind}; a broadcast needs a marketing template (List-Unsubscribe, marketing opt-outs, circuit breaker)`,
7419
+ 409
7420
+ );
7421
+ }
7422
+ if (opts.mustExist && !tpl.body?.html && !tpl.body?.mjml) {
7423
+ throw new BroadcastOperationError("template_not_published", `template "${templateSlug}" has no published body`, 409);
7424
+ }
7425
+ }
7426
+ async function computeBroadcastStats(mailer, idFilter) {
7427
+ return aggregateBroadcastStats(mailer.collections, idFilter);
7428
+ }
7429
+ function capProgress(b, stats) {
7430
+ const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
7431
+ return {
7432
+ recipientCap: cap,
7433
+ sendsSoFar: stats.total,
7434
+ remaining: cap === null ? null : Math.max(0, cap - stats.total)
7435
+ };
7436
+ }
7437
+ async function broadcastStatusBreakdown(mailer, broadcastId) {
7438
+ const rows = await mailer.collections.sends.aggregate([
7439
+ { $match: { broadcastId } },
7440
+ { $group: { _id: "$status", n: { $sum: 1 } } }
7441
+ ]).toArray();
7442
+ return Object.fromEntries(rows.map((r) => [r._id, r.n]));
7443
+ }
7444
+ function broadcastSummary(b) {
7445
+ return {
7446
+ id: String(b._id),
7447
+ slug: b.slug,
7448
+ name: b.name,
7449
+ templateSlug: b.templateSlug,
7450
+ status: b.status,
7451
+ segmentDefinition: b.segmentDefinition,
7452
+ respectRecipientTimezone: b.respectRecipientTimezone === true,
7453
+ recipientCap: b.recipientCap ?? null,
7454
+ order: b.order ?? null,
7455
+ pausedAt: b.pausedAt ?? null,
7456
+ pauseReason: b.pauseReason ?? null,
7457
+ stopRules: b.stopRules ?? null,
7458
+ stopRuleBreach: b.stopRuleBreach ?? null,
7459
+ failureReason: b.failureReason ?? null,
7460
+ scheduledAt: b.scheduledAt,
7461
+ startedAt: b.startedAt,
7462
+ completedAt: b.completedAt,
7463
+ confirmedCount: b.confirmedCount,
7464
+ confirmedAt: b.confirmedAt,
7465
+ confirmedBy: b.confirmedBy,
7466
+ recipientCount: b.recipientCount,
7467
+ createdAt: b.createdAt,
7468
+ createdBy: b.createdBy,
7469
+ updatedAt: b.updatedAt
7470
+ };
7471
+ }
6397
7472
 
6398
7473
  // src/server/api/admin.ts
6399
7474
  var __filename$1 = fileURLToPath(import.meta.url);
@@ -7196,19 +8271,19 @@ function createAdminApiRouter(mailer, opts = {}) {
7196
8271
  const flow = await c.flows.findOne({ slug: req.params.slug });
7197
8272
  if (!flow) return res.status(404).json({ error: "not_found" });
7198
8273
  const { steps, notes, trigger, name, description, goal, audience } = req.body ?? {};
7199
- const set = {
8274
+ const set2 = {
7200
8275
  "draft.lastModifiedBy": req.actor,
7201
8276
  "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
7202
8277
  updatedAt: /* @__PURE__ */ new Date()
7203
8278
  };
7204
- if (Array.isArray(steps)) set["draft.steps"] = steps;
7205
- if (typeof notes === "string") set["draft.notes"] = notes;
7206
- if (trigger?.eventName) set.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
7207
- if (typeof name === "string") set.name = name;
7208
- if (typeof description === "string") set.description = description;
7209
- if (typeof goal === "string") set.goal = goal;
7210
- if (typeof audience === "string") set.audience = audience;
7211
- await c.flows.updateOne({ _id: flow._id }, { $set: set });
8279
+ if (Array.isArray(steps)) set2["draft.steps"] = steps;
8280
+ if (typeof notes === "string") set2["draft.notes"] = notes;
8281
+ if (trigger?.eventName) set2.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
8282
+ if (typeof name === "string") set2.name = name;
8283
+ if (typeof description === "string") set2.description = description;
8284
+ if (typeof goal === "string") set2.goal = goal;
8285
+ if (typeof audience === "string") set2.audience = audience;
8286
+ await c.flows.updateOne({ _id: flow._id }, { $set: set2 });
7212
8287
  await mailer.audit({
7213
8288
  actor: req.actor,
7214
8289
  action: "flow.draft.update",
@@ -7369,25 +8444,25 @@ function createAdminApiRouter(mailer, opts = {}) {
7369
8444
  }
7370
8445
  );
7371
8446
  }
7372
- const set = {
8447
+ const set2 = {
7373
8448
  "draft.lastModifiedBy": req.actor,
7374
8449
  "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
7375
8450
  updatedAt: /* @__PURE__ */ new Date()
7376
8451
  };
7377
- if (typeof subject === "string") set["draft.subject"] = subject;
7378
- if (typeof preheader === "string") set["draft.preheader"] = preheader;
7379
- if (typeof mjml === "string") set["draft.mjml"] = mjml;
7380
- if (typeof html === "string") set["draft.html"] = html;
7381
- if (editorJson !== void 0) set["draft.editorJson"] = editorJson;
7382
- if (typeof notes === "string") set["draft.notes"] = notes;
7383
- if (typeof name === "string") set.name = name;
7384
- if (typeof fromName === "string") set.fromName = fromName;
7385
- if (typeof fromEmail === "string") set.fromEmail = fromEmail;
7386
- if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
7387
- if (kind === "marketing" || kind === "transactional") set.kind = kind;
8452
+ if (typeof subject === "string") set2["draft.subject"] = subject;
8453
+ if (typeof preheader === "string") set2["draft.preheader"] = preheader;
8454
+ if (typeof mjml === "string") set2["draft.mjml"] = mjml;
8455
+ if (typeof html === "string") set2["draft.html"] = html;
8456
+ if (editorJson !== void 0) set2["draft.editorJson"] = editorJson;
8457
+ if (typeof notes === "string") set2["draft.notes"] = notes;
8458
+ if (typeof name === "string") set2.name = name;
8459
+ if (typeof fromName === "string") set2.fromName = fromName;
8460
+ if (typeof fromEmail === "string") set2.fromEmail = fromEmail;
8461
+ if (typeof replyTo === "string" || replyTo === null) set2.replyTo = replyTo;
8462
+ if (kind === "marketing" || kind === "transactional") set2.kind = kind;
7388
8463
  if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
7389
- const resultingKind = set.kind ?? tpl.kind;
7390
- const resultingFromEmail = set.fromEmail ?? tpl.fromEmail;
8464
+ const resultingKind = set2.kind ?? tpl.kind;
8465
+ const resultingFromEmail = set2.fromEmail ?? tpl.fromEmail;
7391
8466
  const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
7392
8467
  if (!senderCheck.ok) {
7393
8468
  return res.status(400).json({
@@ -7397,10 +8472,10 @@ function createAdminApiRouter(mailer, opts = {}) {
7397
8472
  });
7398
8473
  }
7399
8474
  }
7400
- if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set.bodyFormat = bodyFormat;
7401
- if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
7402
- if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
7403
- await c.templates.updateOne({ _id: tpl._id }, { $set: set });
8475
+ if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set2.bodyFormat = bodyFormat;
8476
+ if (typeof trackOpens === "boolean") set2.trackOpens = trackOpens;
8477
+ if (typeof trackClicks === "boolean") set2.trackClicks = trackClicks;
8478
+ await c.templates.updateOne({ _id: tpl._id }, { $set: set2 });
7404
8479
  await mailer.audit({
7405
8480
  actor: req.actor,
7406
8481
  action: "template.draft.update",
@@ -7878,125 +8953,60 @@ function createAdminApiRouter(mailer, opts = {}) {
7878
8953
  );
7879
8954
  r.post(
7880
8955
  "/broadcasts",
7881
- asyncHandler(async (req, res) => {
7882
- const { slug, name, templateSlug, segmentDefinition } = req.body ?? {};
7883
- if (!slug || !name || !templateSlug) {
7884
- return res.status(400).json({ error: "validation_failed", message: "slug, name, templateSlug required" });
7885
- }
7886
- const now = /* @__PURE__ */ new Date();
7887
- try {
7888
- await c.broadcasts.insertOne({
7889
- slug,
7890
- name,
7891
- templateSlug,
7892
- segmentDefinition: segmentDefinition ?? { filters: [{ kind: "subscriptionStatus", equals: "subscribed" }] },
7893
- status: "draft",
7894
- scheduledAt: null,
7895
- startedAt: null,
7896
- completedAt: null,
7897
- confirmationRequired: true,
7898
- confirmedCount: null,
7899
- confirmedAt: null,
7900
- confirmedBy: null,
7901
- recipientCount: null,
7902
- stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
7903
- createdAt: now,
7904
- createdBy: req.actor,
7905
- updatedAt: now
7906
- });
7907
- } catch (err) {
7908
- if (err?.code === 11e3) return res.status(409).json({ error: "slug_taken" });
7909
- throw err;
7910
- }
7911
- await mailer.audit({
7912
- actor: req.actor,
7913
- action: "broadcast.create",
7914
- resource: { collection: "mailer_broadcasts", slug }
7915
- });
8956
+ broadcastHandler(async (req, res) => {
8957
+ const { slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
8958
+ await createBroadcast(
8959
+ mailer,
8960
+ { slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
8961
+ req.actor
8962
+ );
7916
8963
  return res.json({ ok: true, slug });
7917
8964
  })
7918
8965
  );
7919
8966
  r.patch(
7920
8967
  "/broadcasts/:slug",
7921
- asyncHandler(async (req, res) => {
7922
- const b = await c.broadcasts.findOne({ slug: req.params.slug });
7923
- if (!b) return res.status(404).json({ error: "not_found" });
7924
- if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
7925
- const { name, templateSlug, segmentDefinition } = req.body ?? {};
7926
- const set = { updatedAt: /* @__PURE__ */ new Date() };
7927
- if (typeof name === "string") set.name = name;
7928
- if (typeof templateSlug === "string") set.templateSlug = templateSlug;
7929
- if (segmentDefinition) set.segmentDefinition = segmentDefinition;
7930
- await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
8968
+ broadcastHandler(async (req, res) => {
8969
+ const { name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
8970
+ await patchBroadcast(
8971
+ mailer,
8972
+ String(req.params.slug),
8973
+ { name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
8974
+ req.actor
8975
+ );
7931
8976
  return res.json({ ok: true });
7932
8977
  })
7933
8978
  );
7934
8979
  r.post(
7935
8980
  "/broadcasts/:slug/segment/count",
7936
- asyncHandler(async (req, res) => {
7937
- const segmentDefinition = req.body?.segmentDefinition;
7938
- if (!segmentDefinition?.filters) return res.status(400).json({ error: "segment_required" });
7939
- const t0 = Date.now();
7940
- const hostFilter = {};
7941
- for (const f of segmentDefinition.filters) {
7942
- if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
7943
- if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
7944
- }
7945
- const hasMailerFilters = segmentDefinition.filters.some(
7946
- (f) => ["subscriptionStatus", "firedEvent", "notFiredEvent", "notHasTag", "opened", "notOpened", "subscribedAfter", "subscribedBefore"].includes(f.kind)
7947
- );
7948
- const upperBound = await mailer.adapter.count(hostFilter);
8981
+ broadcastHandler(async (req, res) => {
8982
+ const raw = req.body?.segmentDefinition;
8983
+ if (!raw?.filters) return res.status(400).json({ error: "segment_required" });
8984
+ const segment = parseSegment(raw, false);
8985
+ const b = await loadBroadcast(mailer, String(req.params.slug));
8986
+ const count2 = await countRecipients(mailer, b, segment);
7949
8987
  return res.json({
7950
- upperBound,
7951
- approximate: hasMailerFilters,
7952
- computedMs: Date.now() - t0
8988
+ upperBound: count2.recipientCount,
8989
+ approximate: false,
8990
+ computedMs: count2.computedMs,
8991
+ hostMatched: count2.hostMatched,
8992
+ eligible: count2.eligible,
8993
+ alreadySent: count2.alreadySent,
8994
+ recipientCount: count2.recipientCount
7953
8995
  });
7954
8996
  })
7955
8997
  );
7956
8998
  r.post(
7957
8999
  "/broadcasts/:slug/schedule",
7958
- asyncHandler(async (req, res) => {
7959
- const b = await c.broadcasts.findOne({ slug: req.params.slug });
7960
- if (!b) return res.status(404).json({ error: "not_found" });
7961
- if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
9000
+ broadcastHandler(async (req, res) => {
7962
9001
  const { scheduledAt, confirmedCount, respectRecipientTimezone } = req.body ?? {};
7963
- if (!scheduledAt) return res.status(400).json({ error: "scheduledAt_required" });
7964
- const scheduled = new Date(scheduledAt);
7965
- if (Number.isNaN(scheduled.getTime())) return res.status(400).json({ error: "bad_scheduledAt" });
7966
- const threshold = mailer.config.broadcastConfirmationThreshold;
7967
- if (typeof confirmedCount !== "number") {
7968
- return res.status(400).json({ error: "confirmedCount_required" });
7969
- }
7970
- const set = {
7971
- status: "scheduled",
7972
- scheduledAt: scheduled,
7973
- confirmedCount,
7974
- confirmedAt: /* @__PURE__ */ new Date(),
7975
- confirmedBy: req.actor,
7976
- updatedAt: /* @__PURE__ */ new Date()
7977
- };
7978
- if (respectRecipientTimezone) set.respectRecipientTimezone = true;
7979
- await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
7980
- await mailer.audit({
7981
- actor: req.actor,
7982
- action: "broadcast.schedule",
7983
- resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7984
- diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
7985
- });
9002
+ await scheduleBroadcast(mailer, String(req.params.slug), { scheduledAt, confirmedCount, respectRecipientTimezone }, req.actor);
7986
9003
  return res.json({ ok: true });
7987
9004
  })
7988
9005
  );
7989
9006
  r.post(
7990
9007
  "/broadcasts/:slug/cancel",
7991
- asyncHandler(async (req, res) => {
7992
- const b = await c.broadcasts.findOne({ slug: req.params.slug });
7993
- if (!b) return res.status(404).json({ error: "not_found" });
7994
- await c.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
7995
- await mailer.audit({
7996
- actor: req.actor,
7997
- action: "broadcast.cancel",
7998
- resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug }
7999
- });
9008
+ broadcastHandler(async (req, res) => {
9009
+ await cancelBroadcast(mailer, String(req.params.slug), req.actor);
8000
9010
  return res.json({ ok: true });
8001
9011
  })
8002
9012
  );
@@ -8133,35 +9143,15 @@ async function computeTemplateStats(mailer, slugFilter) {
8133
9143
  }
8134
9144
  return out;
8135
9145
  }
8136
- function emptyBroadcastStats() {
8137
- return { delivered: 0, opened: 0, clicked: 0, bounced: 0 };
8138
- }
8139
- async function computeBroadcastStats(mailer, idFilter) {
8140
- const out = /* @__PURE__ */ new Map();
8141
- const match = { broadcastId: { $ne: null } };
8142
- if (idFilter) match.broadcastId = idFilter;
8143
- const rows = await mailer.collections.sends.aggregate([
8144
- { $match: match },
8145
- {
8146
- $group: {
8147
- _id: "$broadcastId",
8148
- delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } },
8149
- opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
8150
- clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
8151
- bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } }
9146
+ function broadcastHandler(fn) {
9147
+ return (req, res, next) => {
9148
+ fn(req, res, next).catch((err) => {
9149
+ if (err instanceof BroadcastOperationError) {
9150
+ return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
8152
9151
  }
8153
- }
8154
- ]).toArray();
8155
- for (const row of rows) {
8156
- if (!row._id) continue;
8157
- out.set(String(row._id), {
8158
- delivered: row.delivered,
8159
- opened: row.opened,
8160
- clicked: row.clicked,
8161
- bounced: row.bounced
9152
+ next(err);
8162
9153
  });
8163
- }
8164
- return out;
9154
+ };
8165
9155
  }
8166
9156
 
8167
9157
  // src/server/api/wrap.ts
@@ -8538,7 +9528,16 @@ function unitToMs2(value, unit) {
8538
9528
  }
8539
9529
 
8540
9530
  // src/server/api/agent.ts
8541
- var VERSION = "0.17.1" ;
9531
+ var VERSION = "0.18.0" ;
9532
+ var broadcastCountSchema = z.object({
9533
+ recipientCap: z.number().int().positive().max(1e7).nullable().optional()
9534
+ });
9535
+ var broadcastTestSendSchema = z.object({
9536
+ contactIds: z.array(z.string().min(1).max(256)).min(1).max(10),
9537
+ vars: z.record(z.string(), z.unknown()).optional(),
9538
+ /** 'queue' leaves the sends queued for the worker instead of dispatching inline. */
9539
+ dispatch: z.enum(["now", "queue"]).optional()
9540
+ });
8542
9541
  var agentTagsInputSchema = z.object({
8543
9542
  add: z.array(z.string().min(1).max(128)).max(25).default([]),
8544
9543
  remove: z.array(z.string().min(1).max(128)).max(25).default([])
@@ -8803,7 +9802,7 @@ function createAgentRouter(mailer, opts) {
8803
9802
  }
8804
9803
  const now = /* @__PURE__ */ new Date();
8805
9804
  const publishedBy = input.publishedBy ?? actorOf(req);
8806
- const set = {
9805
+ const set2 = {
8807
9806
  slug,
8808
9807
  name: input.name,
8809
9808
  description: input.description,
@@ -8834,7 +9833,7 @@ function createAgentRouter(mailer, opts) {
8834
9833
  const result = await c.templates.updateOne(
8835
9834
  { slug },
8836
9835
  {
8837
- $set: set,
9836
+ $set: set2,
8838
9837
  $setOnInsert: {
8839
9838
  createdAt: now,
8840
9839
  stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null }
@@ -9216,6 +10215,152 @@ function createAgentRouter(mailer, opts) {
9216
10215
  res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
9217
10216
  })
9218
10217
  );
10218
+ router.get(
10219
+ "/broadcasts",
10220
+ wrap2(async (_req, res) => {
10221
+ const docs = await c.broadcasts.find().sort({ createdAt: -1 }).limit(200).toArray();
10222
+ const stats = await computeBroadcastStats(mailer);
10223
+ res.json(docs.map((b) => ({ ...broadcastSummary(b), stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
10224
+ })
10225
+ );
10226
+ router.get(
10227
+ "/broadcasts/:slug",
10228
+ wrap2(async (req, res) => {
10229
+ const b = await loadBroadcast(mailer, String(req.params.slug));
10230
+ const [statsMap, statusBreakdown] = await Promise.all([
10231
+ computeBroadcastStats(mailer, b._id),
10232
+ broadcastStatusBreakdown(mailer, b._id)
10233
+ ]);
10234
+ const stats = statsMap.get(String(b._id)) ?? emptyBroadcastStats();
10235
+ res.json({
10236
+ broadcast: broadcastSummary(b),
10237
+ stats,
10238
+ statusBreakdown,
10239
+ capProgress: capProgress(b, stats),
10240
+ stopRules: stopRuleStatus(mailer, b, stats)
10241
+ });
10242
+ })
10243
+ );
10244
+ router.post(
10245
+ "/broadcasts",
10246
+ wrap2(async (req, res) => {
10247
+ const parsed = agentCreateBroadcastSchema.safeParse(req.body ?? {});
10248
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10249
+ const b = await createBroadcast(mailer, parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
10250
+ res.status(201).json({ broadcast: broadcastSummary(b) });
10251
+ })
10252
+ );
10253
+ router.patch(
10254
+ "/broadcasts/:slug",
10255
+ wrap2(async (req, res) => {
10256
+ const parsed = agentPatchBroadcastSchema.safeParse(req.body ?? {});
10257
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10258
+ const b = await patchBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
10259
+ res.json({ broadcast: broadcastSummary(b) });
10260
+ })
10261
+ );
10262
+ router.post(
10263
+ "/broadcasts/:slug/schedule",
10264
+ wrap2(async (req, res) => {
10265
+ const parsed = agentScheduleBroadcastSchema.safeParse(req.body ?? {});
10266
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10267
+ const b = await scheduleBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
10268
+ requireSubscribed: true,
10269
+ requireExactCount: true
10270
+ });
10271
+ res.json({ broadcast: broadcastSummary(b) });
10272
+ })
10273
+ );
10274
+ router.post(
10275
+ "/broadcasts/:slug/count",
10276
+ wrap2(async (req, res) => {
10277
+ const parsed = broadcastCountSchema.safeParse(req.body ?? {});
10278
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10279
+ const b = await loadBroadcast(mailer, String(req.params.slug));
10280
+ const count2 = await countRecipients(
10281
+ mailer,
10282
+ b,
10283
+ void 0,
10284
+ parsed.data.recipientCap !== void 0 ? { recipientCap: parsed.data.recipientCap } : {}
10285
+ );
10286
+ res.json({
10287
+ slug: b.slug,
10288
+ status: b.status,
10289
+ ...count2,
10290
+ confirmationThreshold: mailer.config.broadcastConfirmationThreshold
10291
+ });
10292
+ })
10293
+ );
10294
+ router.post(
10295
+ "/broadcasts/:slug/test-send",
10296
+ wrap2(async (req, res) => {
10297
+ const parsed = broadcastTestSendSchema.safeParse(req.body ?? {});
10298
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10299
+ const b = await loadBroadcast(mailer, String(req.params.slug));
10300
+ const tpl = await loadBroadcastTemplate(mailer, b);
10301
+ if (!tpl.body?.html && !tpl.body?.mjml) {
10302
+ return res.status(409).json({ error: "not_published", message: `template "${tpl.slug}" has no published body` });
10303
+ }
10304
+ const contacts = [];
10305
+ for (const id of parsed.data.contactIds) {
10306
+ const contact = await loadContact(res, id);
10307
+ if (!contact) return;
10308
+ if (!guardTestContact(res, contact)) return;
10309
+ contacts.push(contact);
10310
+ }
10311
+ const ctx = mailer.getRunnerContext();
10312
+ const dispatchNow = parsed.data.dispatch !== "queue";
10313
+ const sends = [];
10314
+ for (const contact of contacts) {
10315
+ const { sendId } = await mailer.sendOneOff({
10316
+ templateSlug: tpl.slug,
10317
+ externalId: contact.externalId,
10318
+ dedupeKey: `broadcast-test:${b._id}:${contact.externalId}:${crypto2.randomUUID()}`,
10319
+ vars: parsed.data.vars
10320
+ });
10321
+ const _id = new ObjectId(sendId);
10322
+ await c.sends.updateOne({ _id }, { $set: { manualSendBy: `broadcast-test:${b.slug}` } });
10323
+ if (dispatchNow) await dispatchSend(_id, ctx);
10324
+ const row = await c.sends.findOne({ _id });
10325
+ if (row) sends.push(sendSummary(row));
10326
+ }
10327
+ await mailer.audit({
10328
+ actor: actorOf(req),
10329
+ action: "agent.broadcast.test-send",
10330
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
10331
+ diffSummary: `template=${tpl.slug} to=${contacts.map((x) => x.email).join(", ")} dispatch=${dispatchNow ? "now" : "queue"}`
10332
+ });
10333
+ res.status(201).json({ broadcast: { slug: b.slug, status: b.status }, templateSlug: tpl.slug, dispatched: dispatchNow, sends });
10334
+ })
10335
+ );
10336
+ router.post(
10337
+ "/broadcasts/:slug/pause",
10338
+ wrap2(async (req, res) => {
10339
+ const parsed = agentPauseBroadcastSchema.safeParse(req.body ?? {});
10340
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10341
+ const out = await pauseBroadcastByOperator(mailer, String(req.params.slug), parsed.data, actorOf(req));
10342
+ res.json({ broadcast: broadcastSummary(out.broadcast), heldSends: out.heldSends });
10343
+ })
10344
+ );
10345
+ router.post(
10346
+ "/broadcasts/:slug/resume",
10347
+ wrap2(async (req, res) => {
10348
+ const parsed = agentResumeBroadcastSchema.safeParse(req.body ?? {});
10349
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10350
+ const b = await resumeBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
10351
+ requireSubscribed: true,
10352
+ requireExactCount: true
10353
+ });
10354
+ res.json({ broadcast: broadcastSummary(b) });
10355
+ })
10356
+ );
10357
+ router.post(
10358
+ "/broadcasts/:slug/cancel",
10359
+ wrap2(async (req, res) => {
10360
+ const out = await cancelBroadcast(mailer, String(req.params.slug), actorOf(req));
10361
+ res.json({ broadcast: broadcastSummary(out.broadcast), cancelledSends: out.cancelledSends });
10362
+ })
10363
+ );
9219
10364
  router.post(
9220
10365
  "/tick",
9221
10366
  wrap2(async (_req, res) => {
@@ -9305,6 +10450,9 @@ function createAgentRouter(mailer, opts) {
9305
10450
  if (err instanceof FlowOperationError) {
9306
10451
  return res.status(err.status).json({ error: err.code, message: err.message });
9307
10452
  }
10453
+ if (err instanceof BroadcastOperationError) {
10454
+ return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
10455
+ }
9308
10456
  if (err?.name === "ZodError") {
9309
10457
  return res.status(400).json({ error: "validation_failed", issues: err.issues });
9310
10458
  }
@@ -9673,6 +10821,9 @@ function runSummary(r) {
9673
10821
  history: r.history
9674
10822
  };
9675
10823
  }
10824
+ function zodMessage(error) {
10825
+ return error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ");
10826
+ }
9676
10827
  function objectOrUndefined(v) {
9677
10828
  return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
9678
10829
  }
@@ -9711,6 +10862,16 @@ var ENDPOINTS = [
9711
10862
  { method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
9712
10863
  { method: "POST", path: "/contacts/:externalId/tags", summary: "Add or remove tags on a test contact ({add: [...], remove: [...]}), so a gated flow lets it through.", testContactsOnly: true },
9713
10864
  { method: "POST", path: "/contacts/:externalId/reset", summary: "Delete a test contact's runs, sends, events ({events: [names]} to narrow) and suppressions, then resubscribe. Each part can be turned off with false.", testContactsOnly: true },
10865
+ { method: "GET", path: "/broadcasts", summary: "Every broadcast (newest first, up to 200) with its stats." },
10866
+ { method: "GET", path: "/broadcasts/:slug", summary: "One broadcast: stats (delivered, bounced hard/soft, complained, unsubscribed, opened, clicked, with rates), a per-status count of its send rows, pause reason and cap progress." },
10867
+ { method: "POST", path: "/broadcasts", summary: "Create a draft broadcast: {slug, name, templateSlug, segmentDefinition?, respectRecipientTimezone?}." },
10868
+ { method: "PATCH", path: "/broadcasts/:slug", summary: "Edit a draft broadcast (name, templateSlug, segmentDefinition, respectRecipientTimezone). 409 once it has left draft." },
10869
+ { method: "POST", path: "/broadcasts/:slug/count", summary: "The true recipient count: host filter, post-filters, suppression, minus contacts already sent to, within the cap ({recipientCap} previews another cap). Schedule wants recipientCount; resume wants recipientCount + heldSends." },
10870
+ { method: "POST", path: "/broadcasts/:slug/schedule", summary: "Schedule a draft: {scheduledAt, confirmedCount, respectRecipientTimezone?}. 409 count_mismatch unless confirmedCount equals POST /broadcasts/:slug/count \u2192 recipientCount. The segment must be limited to subscribed contacts." },
10871
+ { method: "POST", path: "/broadcasts/:slug/test-send", summary: `Send the broadcast's template, rendered as each of {contactIds} (test contacts only), through the real pipeline without scheduling it; not counted in the broadcast's stats. {dispatch: "queue"} to leave them queued.`, testContactsOnly: true },
10872
+ { method: "POST", path: "/broadcasts/:slug/pause", summary: "Pause a sending broadcast by hand ({reason?}); its queued sends are held." },
10873
+ { method: "POST", path: "/broadcasts/:slug/resume", summary: "Re-open a paused broadcast: {recipientCap?, stopRules?, confirmedCount}. Next wave: raise recipientCap (null = no cap). confirmedCount = /count recipientCount + heldSends. A stop-rule pause re-opens only when the (adjusted) rules no longer fire; a circuit-breaker pause only once the breaker is reset." },
10874
+ { method: "POST", path: "/broadcasts/:slug/cancel", summary: "Cancel a broadcast." },
9714
10875
  { method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
9715
10876
  { method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
9716
10877
  { method: "GET", path: "/status", summary: "One document with setup checks, health, every flow (enabled, version, watermark, gate, active runs), every template, and 24h counts." }
@@ -9951,12 +11112,15 @@ function createPublicRouter(mailer, opts = {}) {
9951
11112
  return;
9952
11113
  }
9953
11114
  const now = /* @__PURE__ */ new Date();
11115
+ await mailer.collections.sends.updateOne(
11116
+ { _id: sendId, status: "sent" },
11117
+ { $set: { status: "delivered" } }
11118
+ );
9954
11119
  await mailer.collections.sends.updateOne(
9955
11120
  { _id: sendId },
9956
11121
  {
9957
11122
  $set: {
9958
- openedAt: send.openedAt ?? now,
9959
- status: "delivered"
11123
+ openedAt: send.openedAt ?? now
9960
11124
  },
9961
11125
  $inc: { openCount: 1 },
9962
11126
  // Per-open user agent, so `hasOpenedExcludingBots` has a signal to
@@ -10095,6 +11259,11 @@ function createPublicRouter(mailer, opts = {}) {
10095
11259
  }
10096
11260
  }
10097
11261
  res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
11262
+ if (!dbError && decoded.sendId) {
11263
+ void attributeUnsubscribeToSend(mailer.getRunnerContext(), decoded.sendId, decoded.email).catch((err) => {
11264
+ logger.warn?.({ err, sendId: decoded.sendId }, "mailery: unsubscribe attribution failed");
11265
+ });
11266
+ }
10098
11267
  }));
10099
11268
  router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
10100
11269
  const token = req.params.token;
@@ -10426,7 +11595,7 @@ var DEDUPE_POLICIES = [
10426
11595
  ];
10427
11596
 
10428
11597
  // src/server/index.ts
10429
- var VERSION2 = "0.17.1" ;
11598
+ var VERSION2 = "0.18.0" ;
10430
11599
 
10431
11600
  export { DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, FLOW_STEP_KINDS, FlowOperationError, MIN_AGENT_TOKEN_LENGTH, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, TRACKING_SIG_LENGTH, VERSION2 as VERSION, applyTracking, applyWebhookEvent, armFlow, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminApiRouter, createAdminRouter, createAgentRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, disarmFlow, dispatchSend, drainPendingUnsubscribes, ensureIndexes, gateFlow, getCollections, isCanaryGate, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, referencedPaths, renderForContact, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, simulateFlow, stampWatermarkIfNull, sweepStrandedFlowRuns, ungateFlow, validateSenderDomain, varsJsonSchema, verifyTemplate, verifyTrackingToken, verifyUnsubscribeToken };
10432
11601
  //# sourceMappingURL=index.js.map