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.cjs CHANGED
@@ -106,6 +106,42 @@ __export(mongo_exports, {
106
106
  function canBeObjectId(s) {
107
107
  return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
108
108
  }
109
+ function getPath(doc, path3) {
110
+ let cur = doc;
111
+ for (const part of path3.split(".")) {
112
+ if (cur === null || typeof cur !== "object") return void 0;
113
+ cur = cur[part];
114
+ }
115
+ return cur;
116
+ }
117
+ function isObjectIdLike(v) {
118
+ return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
119
+ }
120
+ function packScalar(v) {
121
+ if (v instanceof Date) return { d: v.toISOString() };
122
+ if (isObjectIdLike(v)) return { o: v.toHexString() };
123
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
124
+ return null;
125
+ }
126
+ function unpackScalar(v) {
127
+ if (v && typeof v === "object") {
128
+ if ("d" in v) return new Date(v.d);
129
+ if ("o" in v) return new mongodb.ObjectId(v.o);
130
+ }
131
+ return v;
132
+ }
133
+ function encodeSortCursor(value, id) {
134
+ return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
135
+ }
136
+ function decodeSortCursor(cursor) {
137
+ try {
138
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
139
+ if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
140
+ return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
109
145
  exports.MongoContactAdapter = void 0;
110
146
  var init_mongo = __esm({
111
147
  "src/server/adapters/mongo.ts"() {
@@ -155,7 +191,10 @@ var init_mongo = __esm({
155
191
  }
156
192
  return out;
157
193
  }
194
+ /** `query` honours `opts.sort` — see `querySorted`. */
195
+ supportsSort = true;
158
196
  async query(filter, opts) {
197
+ if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
159
198
  const query = this.translateFilterFn(filter);
160
199
  const limit = Math.min(opts.limit, this.batchSize);
161
200
  if (opts.cursor) {
@@ -174,6 +213,52 @@ var init_mongo = __esm({
174
213
  const query = this.translateFilterFn(filter);
175
214
  return await this.col.countDocuments(query);
176
215
  }
216
+ /**
217
+ * Keyset pagination in (sort.field, idField) order. The cursor encodes the
218
+ * last row's sort value and id, and the next page is every row strictly
219
+ * after that position. Mongo sorts a null or missing value lowest, so
220
+ * `desc` puts contacts without the field last and `asc` puts them first;
221
+ * the position predicate follows the same rule, so they are neither
222
+ * skipped nor repeated.
223
+ *
224
+ * The field should hold one BSON type (or be absent): range operators only
225
+ * compare within a type. A value that moves while a pass is paging (a
226
+ * contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
227
+ * at all in that pass; broadcast dispatch tolerates both (the per-recipient
228
+ * dedupe key stops a second send, and a later pass picks up a skipped one).
229
+ * Index `{ <field>: -1, <idField>: 1 }` on a large collection.
230
+ */
231
+ async querySorted(filter, limitIn, cursor, sort) {
232
+ const base = this.translateFilterFn(filter);
233
+ const limit = Math.min(limitIn, this.batchSize);
234
+ const dir = sort.direction === "desc" ? -1 : 1;
235
+ let query = base;
236
+ if (cursor) {
237
+ const pos = decodeSortCursor(cursor);
238
+ if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
239
+ query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
240
+ }
241
+ const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
242
+ const hasMore = docs.length > limit;
243
+ const slice = hasMore ? docs.slice(0, limit) : docs;
244
+ const last = slice[slice.length - 1];
245
+ return {
246
+ contacts: slice.map((d) => this.toContactFn(d)),
247
+ nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
248
+ };
249
+ }
250
+ afterPosition(field, dir, value, id) {
251
+ const idF = this.idField;
252
+ const isNull = value === null || value === void 0;
253
+ if (dir === -1) {
254
+ if (isNull) return { [field]: null, [idF]: { $gt: id } };
255
+ return {
256
+ $or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
257
+ };
258
+ }
259
+ if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
260
+ return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
261
+ }
177
262
  addTags;
178
263
  removeTags;
179
264
  // -------------------------------------------------------------------------
@@ -523,6 +608,35 @@ var sendOneOffInputSchema = zod.z.object({
523
608
  providerOverride: zod.z.string().optional(),
524
609
  dedupeKey: zod.z.string().min(1).max(512)
525
610
  });
611
+ var subscriptionStatusEnum = zod.z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
612
+ var fieldNameSchema = (strict) => (strict ? zod.z.string().min(1) : zod.z.string()).max(256).regex(/^(?!\$)[^\0]*$/, "must not start with $");
613
+ var segmentValueSchema = zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()]);
614
+ function segmentFilterSchema(strict) {
615
+ const str = strict ? zod.z.string().min(1).max(256) : zod.z.string().max(256);
616
+ const days = zod.z.number().int().positive().max(36500).optional();
617
+ const self = zod.z.lazy(
618
+ () => zod.z.discriminatedUnion("kind", [
619
+ zod.z.object({ kind: zod.z.literal("fieldEquals"), field: fieldNameSchema(strict), value: segmentValueSchema }),
620
+ zod.z.object({ kind: zod.z.literal("fieldIn"), field: fieldNameSchema(strict), values: zod.z.array(segmentValueSchema).max(1e3) }),
621
+ zod.z.object({ kind: zod.z.literal("fieldExists"), field: fieldNameSchema(strict) }),
622
+ zod.z.object({ kind: zod.z.literal("hasTag"), tag: str }),
623
+ zod.z.object({ kind: zod.z.literal("notHasTag"), tag: str }),
624
+ zod.z.object({ kind: zod.z.literal("subscriptionStatus"), equals: subscriptionStatusEnum }),
625
+ zod.z.object({ kind: zod.z.literal("firedEvent"), eventName: str, withinDays: days }),
626
+ zod.z.object({ kind: zod.z.literal("notFiredEvent"), eventName: str, withinDays: days }),
627
+ zod.z.object({ kind: zod.z.literal("subscribedAfter"), date: zod.z.coerce.date() }),
628
+ zod.z.object({ kind: zod.z.literal("subscribedBefore"), date: zod.z.coerce.date() }),
629
+ zod.z.object({ kind: zod.z.literal("opened"), templateSlug: slugSchema.optional(), withinDays: days }),
630
+ zod.z.object({ kind: zod.z.literal("notOpened"), templateSlug: slugSchema.optional(), withinDays: days }),
631
+ zod.z.object({ kind: zod.z.literal("any"), filters: strict ? zod.z.array(self).min(1).max(50) : zod.z.array(self).max(50) }),
632
+ zod.z.object({ kind: zod.z.literal("not"), filter: self })
633
+ ])
634
+ );
635
+ return self;
636
+ }
637
+ function segmentDefinitionSchema(strict) {
638
+ return zod.z.object({ filters: zod.z.array(segmentFilterSchema(strict)).max(50) });
639
+ }
526
640
  var flowStepSchema = zod.z.lazy(
527
641
  () => zod.z.discriminatedUnion("type", [
528
642
  zod.z.object({
@@ -691,6 +805,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
691
805
  windowMinutes: 60,
692
806
  minSendsBeforeEval: 100
693
807
  };
808
+ var BROADCAST_STOP_RULE_DEFAULTS = {
809
+ enabled: true,
810
+ hardBounceRatePct: 2,
811
+ complaintRatePct: 0.1,
812
+ unsubscribeRatePct: 1,
813
+ minSample: 100
814
+ };
694
815
  function resolveConfig(c) {
695
816
  return {
696
817
  ...c,
@@ -718,7 +839,8 @@ function resolveConfig(c) {
718
839
  storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
719
840
  requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
720
841
  trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
721
- circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
842
+ circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
843
+ broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
722
844
  };
723
845
  }
724
846
 
@@ -936,11 +1058,13 @@ function resolveProvider(providers, name) {
936
1058
  function registeredProviderNames(providers) {
937
1059
  return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
938
1060
  }
1061
+ var SEND_ID_RE = /^[a-f0-9]{24}$/i;
939
1062
  function signUnsubscribeToken(payload, secret) {
940
1063
  const body = JSON.stringify({
941
1064
  e: payload.email.toLowerCase(),
942
1065
  s: payload.scope,
943
- x: payload.expiresAt.getTime()
1066
+ x: payload.expiresAt.getTime(),
1067
+ ...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
944
1068
  });
945
1069
  const bodyB64 = b64url(Buffer.from(body, "utf8"));
946
1070
  const hmac = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
@@ -971,7 +1095,8 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
971
1095
  return {
972
1096
  email: body.e,
973
1097
  scope: body.s,
974
- expiresAt: new Date(body.x)
1098
+ expiresAt: new Date(body.x),
1099
+ ...typeof body.i === "string" && SEND_ID_RE.test(body.i) ? { sendId: body.i } : {}
975
1100
  };
976
1101
  }
977
1102
  var TRACKING_SCOPES = ["open", "click"];
@@ -1746,6 +1871,211 @@ function effectiveOverallStatus(docs) {
1746
1871
  if (buckets.some((d) => d.status === "degraded")) return "degraded";
1747
1872
  return "healthy";
1748
1873
  }
1874
+ function emptyBroadcastStats() {
1875
+ return withRates({
1876
+ total: 0,
1877
+ accepted: 0,
1878
+ delivered: 0,
1879
+ bounced: 0,
1880
+ hardBounced: 0,
1881
+ softBounced: 0,
1882
+ complained: 0,
1883
+ unsubscribed: 0,
1884
+ opened: 0,
1885
+ clicked: 0,
1886
+ outcomes: 0
1887
+ });
1888
+ }
1889
+ var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
1890
+ function withRates(c) {
1891
+ return {
1892
+ ...c,
1893
+ rates: {
1894
+ deliveryRatePct: pct(c.delivered, c.accepted),
1895
+ bounceRatePct: pct(c.bounced, c.outcomes),
1896
+ hardBounceRatePct: pct(c.hardBounced, c.outcomes),
1897
+ complaintRatePct: pct(c.complained, c.outcomes),
1898
+ unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
1899
+ openRatePct: pct(c.opened, c.delivered),
1900
+ clickRatePct: pct(c.clicked, c.delivered)
1901
+ }
1902
+ };
1903
+ }
1904
+ var set = (field) => ({ $ifNull: [field, false] });
1905
+ var DELIVERED = {
1906
+ $or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
1907
+ };
1908
+ var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
1909
+ var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
1910
+ async function aggregateBroadcastStats(collections, broadcastId) {
1911
+ const rows = await collections.sends.aggregate([
1912
+ { $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
1913
+ {
1914
+ $group: {
1915
+ _id: "$broadcastId",
1916
+ total: { $sum: 1 },
1917
+ accepted: count(set("$sentAt")),
1918
+ delivered: count(DELIVERED),
1919
+ bounced: count(BOUNCED),
1920
+ hardBounced: count({ $eq: ["$bounceType", "hard"] }),
1921
+ softBounced: count({ $eq: ["$bounceType", "soft"] }),
1922
+ complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
1923
+ unsubscribed: count(set("$unsubscribedAt")),
1924
+ opened: count(set("$openedAt")),
1925
+ clicked: count(set("$firstClickAt")),
1926
+ outcomes: count({ $or: [DELIVERED, BOUNCED] })
1927
+ }
1928
+ }
1929
+ ]).toArray();
1930
+ const out = /* @__PURE__ */ new Map();
1931
+ for (const { _id, ...c } of rows) {
1932
+ if (_id) out.set(String(_id), withRates(c));
1933
+ }
1934
+ return out;
1935
+ }
1936
+ async function broadcastStatsFor(collections, broadcastId) {
1937
+ return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
1938
+ }
1939
+ function effectiveStopRules(config, b) {
1940
+ return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
1941
+ }
1942
+ var RULE_COUNTS = [
1943
+ ["complaintRatePct", "complained"],
1944
+ ["hardBounceRatePct", "hardBounced"],
1945
+ ["unsubscribeRatePct", "unsubscribed"]
1946
+ ];
1947
+ function evaluateStopRules(stats, rules) {
1948
+ const sample = stats.outcomes;
1949
+ const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
1950
+ const breaches = [];
1951
+ if (evaluated) {
1952
+ for (const [rule, key] of RULE_COUNTS) {
1953
+ const n = stats[key];
1954
+ const exactPct = n / sample * 100;
1955
+ if (exactPct > rules[rule]) {
1956
+ breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
1957
+ }
1958
+ }
1959
+ }
1960
+ return { rules, sample, evaluated, breaches };
1961
+ }
1962
+ var RULE_LABEL = {
1963
+ complaintRatePct: "complaint rate",
1964
+ hardBounceRatePct: "hard bounce rate",
1965
+ unsubscribeRatePct: "unsubscribe rate"
1966
+ };
1967
+ var PAUSE_PRECEDENCE = {
1968
+ manual: 1,
1969
+ cap_reached: 1,
1970
+ stop_rule: 2,
1971
+ circuit_breaker: 2
1972
+ };
1973
+ async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
1974
+ const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
1975
+ (k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
1976
+ );
1977
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
1978
+ {
1979
+ _id: broadcastId,
1980
+ $or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
1981
+ },
1982
+ { $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
1983
+ { returnDocument: "after" }
1984
+ );
1985
+ if (!b) return { paused: false, heldSends: 0 };
1986
+ const held = await holdQueuedSends(ctx, broadcastId, reason.code);
1987
+ if (ctx.audit) {
1988
+ await ctx.audit({
1989
+ actor,
1990
+ action: "broadcast.pause",
1991
+ resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
1992
+ diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
1993
+ }).catch(() => {
1994
+ });
1995
+ }
1996
+ if (ctx.config.onBroadcastPaused) {
1997
+ try {
1998
+ await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
1999
+ } catch {
2000
+ }
2001
+ }
2002
+ return { paused: true, heldSends: held };
2003
+ }
2004
+ async function holdQueuedSends(ctx, broadcastId, code) {
2005
+ const res = await ctx.collections.sends.updateMany(
2006
+ { broadcastId, status: "queued" },
2007
+ { $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
2008
+ );
2009
+ return res.modifiedCount;
2010
+ }
2011
+ async function releaseHeldSends(ctx, broadcastId) {
2012
+ const held = await ctx.collections.sends.find({ broadcastId, status: "held" }, { projection: { _id: 1, notBefore: 1 } }).toArray();
2013
+ if (held.length === 0) return 0;
2014
+ await ctx.collections.sends.updateMany(
2015
+ { _id: { $in: held.map((s) => s._id) }, status: "held" },
2016
+ { $set: { status: "queued", errorMessage: null, updatedAt: /* @__PURE__ */ new Date() } }
2017
+ );
2018
+ const now = Date.now();
2019
+ await Promise.all(
2020
+ held.map((s) => {
2021
+ const delay = s.notBefore ? Math.max(0, new Date(s.notBefore).getTime() - now) : 0;
2022
+ return ctx.queues.send.add(
2023
+ "send",
2024
+ { sendId: String(s._id) },
2025
+ {
2026
+ attempts: ctx.config.sendRetryAttempts,
2027
+ backoff: { type: "exponential", delay: 6e4 },
2028
+ ...delay > 0 ? { delay } : {}
2029
+ }
2030
+ );
2031
+ })
2032
+ );
2033
+ return held.length;
2034
+ }
2035
+ async function evaluateBroadcastStopRules(ctx, broadcastId) {
2036
+ const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
2037
+ if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
2038
+ if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
2039
+ const stats = await broadcastStatsFor(ctx.collections, broadcastId);
2040
+ const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
2041
+ if (evaluation.breaches.length === 0) return evaluation;
2042
+ const now = /* @__PURE__ */ new Date();
2043
+ if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
2044
+ await ctx.collections.broadcasts.updateOne(
2045
+ { _id: broadcastId, stopRuleBreach: { $in: [null] } },
2046
+ { $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
2047
+ );
2048
+ return evaluation;
2049
+ }
2050
+ const first = evaluation.breaches[0];
2051
+ await pauseBroadcast(ctx, broadcastId, {
2052
+ code: "stop_rule",
2053
+ message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
2054
+ at: now,
2055
+ details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
2056
+ });
2057
+ return evaluation;
2058
+ }
2059
+ var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
2060
+ async function evaluateActiveBroadcastStopRules(ctx) {
2061
+ const live = await ctx.collections.broadcasts.find(
2062
+ { status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
2063
+ { projection: { _id: 1 } }
2064
+ ).limit(200).toArray();
2065
+ for (const { _id } of live) {
2066
+ await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
2067
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
2068
+ });
2069
+ }
2070
+ }
2071
+ async function attributeUnsubscribeToSend(ctx, sendId, email) {
2072
+ if (!mongodb.ObjectId.isValid(sendId)) return;
2073
+ const _id = new mongodb.ObjectId(sendId);
2074
+ const send = await ctx.collections.sends.findOne({ _id }, { projection: { emailAtSend: 1, broadcastId: 1 } });
2075
+ if (!send || send.emailAtSend.toLowerCase() !== email.toLowerCase()) return;
2076
+ await ctx.collections.sends.updateOne({ _id, unsubscribedAt: null }, { $set: { unsubscribedAt: /* @__PURE__ */ new Date() } });
2077
+ if (send.broadcastId) await evaluateBroadcastStopRules(ctx, send.broadcastId);
2078
+ }
1749
2079
 
1750
2080
  // src/server/runner/webhook.ts
1751
2081
  function dimsFromSend(send) {
@@ -1900,6 +2230,11 @@ async function applyWebhookEvent(event, ctx) {
1900
2230
  );
1901
2231
  break;
1902
2232
  }
2233
+ if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
2234
+ await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
2235
+ console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
2236
+ });
2237
+ }
1903
2238
  }
1904
2239
  async function suppressOnce(ctx, email, reason, scope) {
1905
2240
  const normalized = email.toLowerCase();
@@ -2311,11 +2646,30 @@ async function isSuppressed(collections, email, kind) {
2311
2646
  if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
2312
2647
  const hashed = await collections.suppressions.findOne({
2313
2648
  emailHash: sha256Hex(normalized),
2314
- scope: { $in: allowed }
2649
+ scope: { $in: allowed },
2650
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
2315
2651
  });
2316
2652
  if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
2317
2653
  return { suppressed: false };
2318
2654
  }
2655
+ async function suppressedEmails(collections, emails, kind) {
2656
+ const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
2657
+ if (normalized.length === 0) return /* @__PURE__ */ new Set();
2658
+ const allowed = SCOPES_BY_KIND[kind];
2659
+ const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
2660
+ const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
2661
+ const [plain, hashed] = await Promise.all([
2662
+ collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
2663
+ collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
2664
+ ]);
2665
+ const out = /* @__PURE__ */ new Set();
2666
+ for (const e of plain) if (typeof e === "string") out.add(e);
2667
+ for (const h of hashed) {
2668
+ const e = byHash.get(String(h));
2669
+ if (e) out.add(e);
2670
+ }
2671
+ return out;
2672
+ }
2319
2673
 
2320
2674
  // src/server/runner/send.ts
2321
2675
  async function handleSend(run, step, contact, flow, ctx) {
@@ -2392,6 +2746,27 @@ async function dispatchSend(sendId, ctx) {
2392
2746
  await markFailed(send._id, "template_missing", ctx);
2393
2747
  return;
2394
2748
  }
2749
+ if (send.broadcastId) {
2750
+ const broadcast = await ctx.collections.broadcasts.findOne(
2751
+ { _id: send.broadcastId },
2752
+ { projection: { status: 1, pauseReason: 1 } }
2753
+ );
2754
+ const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
2755
+ if (holding || broadcast?.status === "cancelled") {
2756
+ const held = holding;
2757
+ await ctx.collections.sends.updateOne(
2758
+ { _id: send._id },
2759
+ {
2760
+ $set: {
2761
+ status: held ? "held" : "cancelled",
2762
+ errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
2763
+ updatedAt: /* @__PURE__ */ new Date()
2764
+ }
2765
+ }
2766
+ );
2767
+ return;
2768
+ }
2769
+ }
2395
2770
  const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
2396
2771
  if (supp.suppressed) {
2397
2772
  await ctx.collections.sends.updateOne(
@@ -2402,6 +2777,19 @@ async function dispatchSend(sendId, ctx) {
2402
2777
  }
2403
2778
  if (send.kind === "marketing") {
2404
2779
  const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
2780
+ if (bucket?.status === "tripped" && send.broadcastId) {
2781
+ await ctx.collections.sends.updateOne(
2782
+ { _id: send._id },
2783
+ { $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
2784
+ );
2785
+ await pauseBroadcast(ctx, send.broadcastId, {
2786
+ code: "circuit_breaker",
2787
+ message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
2788
+ at: /* @__PURE__ */ new Date(),
2789
+ details: { bucket: bucket._id }
2790
+ });
2791
+ return;
2792
+ }
2405
2793
  if (bucket?.status === "tripped") {
2406
2794
  await ctx.collections.sends.updateOne(
2407
2795
  { _id: send._id },
@@ -2434,7 +2822,7 @@ async function dispatchSend(sendId, ctx) {
2434
2822
  eventName: run?.triggerEvent?.name,
2435
2823
  eventProperties: run?.triggerEvent?.properties
2436
2824
  });
2437
- renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
2825
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
2438
2826
  rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
2439
2827
  } catch (err) {
2440
2828
  await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
@@ -2525,11 +2913,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
2525
2913
  }
2526
2914
  return ctx.config.defaultProvider;
2527
2915
  }
2528
- function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
2916
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
2529
2917
  const scope = "marketing";
2530
2918
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
2531
2919
  const token = signUnsubscribeToken(
2532
- { email: contact.email, scope, expiresAt },
2920
+ { email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
2533
2921
  ctx.config.unsubscribeSecret
2534
2922
  );
2535
2923
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
@@ -2905,6 +3293,184 @@ async function sweepStrandedFlowRuns(ctx) {
2905
3293
  }
2906
3294
  }
2907
3295
  }
3296
+
3297
+ // src/server/runner/segment.ts
3298
+ function planSegment(seg) {
3299
+ const hostFilter = {};
3300
+ const postFilters = [];
3301
+ const hostFields = /* @__PURE__ */ new Set();
3302
+ const takeField = (field) => {
3303
+ if (hostFields.has(field)) return false;
3304
+ hostFields.add(field);
3305
+ return true;
3306
+ };
3307
+ for (const f of seg.filters ?? []) {
3308
+ switch (f.kind) {
3309
+ case "hasTag":
3310
+ if (hostFilter.hasTag === void 0) {
3311
+ hostFilter.hasTag = f.tag;
3312
+ continue;
3313
+ }
3314
+ break;
3315
+ case "fieldEquals":
3316
+ if (!hostFilter.fieldEquals && takeField(f.field)) {
3317
+ hostFilter.fieldEquals = { field: f.field, value: f.value };
3318
+ continue;
3319
+ }
3320
+ break;
3321
+ case "fieldIn":
3322
+ if (!hostFilter.fieldIn && takeField(f.field)) {
3323
+ hostFilter.fieldIn = { field: f.field, values: f.values };
3324
+ continue;
3325
+ }
3326
+ break;
3327
+ case "fieldExists":
3328
+ if (!hostFilter.fieldExists && takeField(f.field)) {
3329
+ hostFilter.fieldExists = f.field;
3330
+ continue;
3331
+ }
3332
+ break;
3333
+ }
3334
+ postFilters.push(f);
3335
+ }
3336
+ return { hostFilter, postFilters };
3337
+ }
3338
+ async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
3339
+ if (filters.length === 0 || contacts.length === 0) return contacts;
3340
+ const externalIds = contacts.map((c) => c.externalId);
3341
+ const leaves = /* @__PURE__ */ new Map();
3342
+ collectLookups(filters, leaves);
3343
+ const cache = /* @__PURE__ */ new Map();
3344
+ await Promise.all(
3345
+ [...leaves].map(async ([key, f]) => {
3346
+ cache.set(key, await lookup(f, externalIds, ctx, now));
3347
+ })
3348
+ );
3349
+ return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
3350
+ }
3351
+ function lookupKey(f) {
3352
+ switch (f.kind) {
3353
+ case "subscriptionStatus":
3354
+ return `sub:${f.equals}`;
3355
+ case "firedEvent":
3356
+ case "notFiredEvent":
3357
+ return `evt:${f.withinDays ?? ""}:${f.eventName}`;
3358
+ case "opened":
3359
+ case "notOpened":
3360
+ return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
3361
+ case "subscribedAfter":
3362
+ return `subAfter:${toDate(f.date).getTime()}`;
3363
+ case "subscribedBefore":
3364
+ return `subBefore:${toDate(f.date).getTime()}`;
3365
+ default:
3366
+ return null;
3367
+ }
3368
+ }
3369
+ function collectLookups(filters, out) {
3370
+ for (const f of filters) {
3371
+ if (f.kind === "any") collectLookups(f.filters, out);
3372
+ else if (f.kind === "not") collectLookups([f.filter], out);
3373
+ else {
3374
+ const key = lookupKey(f);
3375
+ if (key && !out.has(key)) out.set(key, f);
3376
+ }
3377
+ }
3378
+ }
3379
+ async function lookup(f, externalIds, ctx, now) {
3380
+ const c = ctx.collections;
3381
+ const inPage = { $in: externalIds };
3382
+ const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
3383
+ let ids;
3384
+ switch (f.kind) {
3385
+ case "subscriptionStatus":
3386
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
3387
+ break;
3388
+ case "firedEvent":
3389
+ case "notFiredEvent": {
3390
+ const cutoff = since(f.withinDays);
3391
+ ids = await c.events.distinct("externalId", {
3392
+ externalId: inPage,
3393
+ name: f.eventName,
3394
+ ...cutoff ? { occurredAt: { $gt: cutoff } } : {}
3395
+ });
3396
+ break;
3397
+ }
3398
+ case "opened":
3399
+ case "notOpened": {
3400
+ const cutoff = since(f.withinDays);
3401
+ ids = await c.sends.distinct("externalId", {
3402
+ externalId: inPage,
3403
+ openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
3404
+ ...f.templateSlug ? { templateSlug: f.templateSlug } : {}
3405
+ });
3406
+ break;
3407
+ }
3408
+ case "subscribedAfter":
3409
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
3410
+ break;
3411
+ case "subscribedBefore":
3412
+ ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
3413
+ break;
3414
+ default:
3415
+ ids = [];
3416
+ }
3417
+ return new Set(ids.map(String));
3418
+ }
3419
+ function matches(c, f, cache) {
3420
+ switch (f.kind) {
3421
+ case "subscriptionStatus":
3422
+ case "firedEvent":
3423
+ case "opened":
3424
+ case "subscribedAfter":
3425
+ case "subscribedBefore":
3426
+ return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
3427
+ case "notFiredEvent":
3428
+ case "notOpened":
3429
+ return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
3430
+ case "hasTag":
3431
+ return c.tags.includes(f.tag);
3432
+ case "notHasTag":
3433
+ return !c.tags.includes(f.tag);
3434
+ case "fieldEquals":
3435
+ return valuesEqual(fieldValue(c, f.field), f.value);
3436
+ case "fieldIn": {
3437
+ const v = fieldValue(c, f.field);
3438
+ return f.values.some((x) => valuesEqual(v, x));
3439
+ }
3440
+ case "fieldExists":
3441
+ return fieldValue(c, f.field) !== void 0;
3442
+ case "any":
3443
+ return f.filters.some((sub) => matches(c, sub, cache));
3444
+ case "not":
3445
+ return !matches(c, f.filter, cache);
3446
+ default:
3447
+ throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
3448
+ }
3449
+ }
3450
+ function fieldValue(c, path3) {
3451
+ let cur = c.fields;
3452
+ for (const part of path3.split(".")) {
3453
+ if (cur === null || typeof cur !== "object") return void 0;
3454
+ cur = cur[part];
3455
+ }
3456
+ return cur;
3457
+ }
3458
+ function valuesEqual(a, b) {
3459
+ if (a instanceof Date || b instanceof Date) {
3460
+ const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
3461
+ const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
3462
+ return !Number.isNaN(ta) && ta === tb;
3463
+ }
3464
+ if (a && typeof a === "object" && typeof a.toHexString === "function") {
3465
+ return String(a) === String(b);
3466
+ }
3467
+ return a === b;
3468
+ }
3469
+ function toDate(d) {
3470
+ return d instanceof Date ? d : new Date(d);
3471
+ }
3472
+
3473
+ // src/server/runner/broadcasts.ts
2908
3474
  var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
2909
3475
  async function processScheduledBroadcasts(ctx) {
2910
3476
  const now = /* @__PURE__ */ new Date();
@@ -2912,7 +3478,7 @@ async function processScheduledBroadcasts(ctx) {
2912
3478
  for (const b of due) {
2913
3479
  const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
2914
3480
  { _id: b._id, status: "scheduled" },
2915
- { $set: { status: "sending", startedAt: now, updatedAt: now } },
3481
+ { $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
2916
3482
  { returnDocument: "after" }
2917
3483
  );
2918
3484
  if (!claimed) continue;
@@ -2920,8 +3486,14 @@ async function processScheduledBroadcasts(ctx) {
2920
3486
  }
2921
3487
  }
2922
3488
  async function startBroadcastDispatch(broadcast, ctx) {
3489
+ const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
3490
+ { _id: broadcast._id },
3491
+ { $inc: { dispatchGeneration: 1 } },
3492
+ { returnDocument: "after" }
3493
+ );
3494
+ const current = bumped ?? broadcast;
2923
3495
  if (ctx.config.queue.driver === "noop") {
2924
- await runBroadcastDispatch(broadcast, ctx);
3496
+ await runBroadcastDispatch(current, ctx);
2925
3497
  return;
2926
3498
  }
2927
3499
  await ctx.queues.advance.add(
@@ -2930,7 +3502,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
2930
3502
  {
2931
3503
  attempts: 3,
2932
3504
  backoff: { type: "exponential", delay: 6e4 },
2933
- jobId: `broadcast-dispatch:${broadcast._id}`
3505
+ jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
2934
3506
  }
2935
3507
  );
2936
3508
  }
@@ -2943,10 +3515,11 @@ async function resumeStalledBroadcasts(ctx) {
2943
3515
  const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
2944
3516
  const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
2945
3517
  for (const b of stalled) {
2946
- await ctx.collections.broadcasts.updateOne(
2947
- { _id: b._id },
2948
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
3518
+ const reset = await ctx.collections.broadcasts.updateOne(
3519
+ { _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
3520
+ { $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
2949
3521
  );
3522
+ if (reset.modifiedCount === 0) continue;
2950
3523
  await startBroadcastDispatch(b, ctx);
2951
3524
  }
2952
3525
  }
@@ -2956,166 +3529,196 @@ async function runBroadcastDispatch(broadcast, ctx) {
2956
3529
  } catch (err) {
2957
3530
  console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
2958
3531
  await ctx.collections.broadcasts.updateOne(
2959
- { _id: broadcast._id },
2960
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
3532
+ { _id: broadcast._id, status: "sending" },
3533
+ {
3534
+ $set: {
3535
+ status: "failed",
3536
+ failureReason: `dispatch error: ${String(err?.message ?? err)}`,
3537
+ updatedAt: /* @__PURE__ */ new Date(),
3538
+ dispatchLeaseId: null
3539
+ }
3540
+ }
2961
3541
  );
2962
3542
  }
2963
3543
  }
3544
+ function broadcastDedupeKey(broadcastId, externalId) {
3545
+ return `broadcast:${broadcastId}:${externalId}`;
3546
+ }
3547
+ var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3548
+ function isSendableEmail(email) {
3549
+ return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
3550
+ }
3551
+ async function* eligibleRecipientPages(broadcast, kind, ctx) {
3552
+ const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
3553
+ const sort = broadcast.order ?? void 0;
3554
+ if (sort && !ctx.adapter.supportsSort) {
3555
+ throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
3556
+ }
3557
+ let cursor;
3558
+ for (; ; ) {
3559
+ const page = await ctx.adapter.query(hostFilter, {
3560
+ limit: ctx.config.broadcastEnqueueBatchSize,
3561
+ cursor,
3562
+ ...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
3563
+ });
3564
+ if (page.contacts.length === 0) break;
3565
+ const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
3566
+ const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
3567
+ yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
3568
+ if (!page.nextCursor) break;
3569
+ cursor = page.nextCursor;
3570
+ }
3571
+ }
3572
+ async function alreadyDispatched(ctx, broadcastId, contacts) {
3573
+ if (contacts.length === 0) return /* @__PURE__ */ new Set();
3574
+ const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
3575
+ const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
3576
+ return new Set(found.map(String));
3577
+ }
3578
+ async function countBroadcastRecipients(broadcast, kind, ctx) {
3579
+ const t0 = Date.now();
3580
+ const { hostFilter } = planSegment(broadcast.segmentDefinition);
3581
+ const [hostMatched, sendsSoFar] = await Promise.all([
3582
+ ctx.adapter.count(hostFilter),
3583
+ broadcast._id ? ctx.collections.sends.countDocuments({ broadcastId: broadcast._id }) : Promise.resolve(0)
3584
+ ]);
3585
+ let eligible = 0;
3586
+ let alreadySent = 0;
3587
+ for await (const page of eligibleRecipientPages(broadcast, kind, ctx)) {
3588
+ eligible += page.length;
3589
+ if (broadcast._id) alreadySent += (await alreadyDispatched(ctx, broadcast._id, page)).size;
3590
+ }
3591
+ const cap = typeof broadcast.recipientCap === "number" ? broadcast.recipientCap : null;
3592
+ const uncapped = eligible - alreadySent;
3593
+ const recipientCount = cap === null ? uncapped : Math.max(0, Math.min(uncapped, cap - sendsSoFar));
3594
+ return {
3595
+ hostMatched,
3596
+ eligible,
3597
+ alreadySent,
3598
+ sendsSoFar,
3599
+ recipientCap: cap,
3600
+ uncappedRecipientCount: uncapped,
3601
+ recipientCount,
3602
+ computedMs: Date.now() - t0
3603
+ };
3604
+ }
2964
3605
  async function dispatchBroadcast(broadcast, ctx) {
2965
3606
  const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
2966
- if (!template) {
3607
+ const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
3608
+ if (failure || !template) {
2967
3609
  await ctx.collections.broadcasts.updateOne(
2968
- { _id: broadcast._id },
2969
- { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
3610
+ { _id: broadcast._id, status: "sending" },
3611
+ { $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
2970
3612
  );
2971
3613
  return;
2972
3614
  }
2973
- const hostFilter = toAdapterFilter(broadcast.segmentDefinition);
2974
- const postFilters = broadcast.segmentDefinition.filters.filter((f) => isMailerSide(f));
2975
- let cursor = void 0;
2976
- let total = 0;
2977
- const batchSize = ctx.config.broadcastEnqueueBatchSize;
3615
+ const leaseId = new mongodb.ObjectId().toHexString();
3616
+ const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
3617
+ const b = await ctx.collections.broadcasts.findOneAndUpdate(
3618
+ {
3619
+ _id: broadcast._id,
3620
+ status: "sending",
3621
+ $or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
3622
+ },
3623
+ { $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
3624
+ { returnDocument: "after" }
3625
+ );
3626
+ if (!b) return;
3627
+ const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
3628
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
3629
+ { $set: { updatedAt: /* @__PURE__ */ new Date() } }
3630
+ )).matchedCount === 1;
2978
3631
  const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
2979
- const respectTimezone = broadcast.respectRecipientTimezone === true;
2980
- const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
2981
- for (; ; ) {
2982
- await ctx.collections.broadcasts.updateOne(
2983
- { _id: broadcast._id },
2984
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
2985
- );
2986
- const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
2987
- if (page.contacts.length === 0) break;
2988
- const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
2989
- if (eligible.length > 0) {
3632
+ const respectTimezone = b.respectRecipientTimezone === true;
3633
+ const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
3634
+ const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
3635
+ let capReached = false;
3636
+ try {
3637
+ for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
3638
+ if (!await holdsLease()) return;
3639
+ const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
3640
+ if (bucket?.status === "tripped") {
3641
+ await pauseBroadcast(ctx, b._id, {
3642
+ code: "circuit_breaker",
3643
+ message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
3644
+ at: /* @__PURE__ */ new Date(),
3645
+ details: { bucket: bucket._id }
3646
+ });
3647
+ return;
3648
+ }
3649
+ const seen = await alreadyDispatched(ctx, b._id, eligible);
3650
+ let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
3651
+ if (fresh.length === 0) continue;
3652
+ if (cap !== null) {
3653
+ const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
3654
+ if (room <= 0) {
3655
+ capReached = true;
3656
+ break;
3657
+ }
3658
+ if (fresh.length > room) {
3659
+ fresh = fresh.slice(0, room);
3660
+ capReached = true;
3661
+ }
3662
+ }
2990
3663
  while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
2991
- await ctx.collections.broadcasts.updateOne(
2992
- { _id: broadcast._id },
2993
- { $set: { updatedAt: /* @__PURE__ */ new Date() } }
2994
- );
3664
+ if (!await holdsLease()) return;
2995
3665
  await sleep(2e3);
2996
3666
  }
2997
- const sendDocs = await Promise.all(
2998
- eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
2999
- );
3000
3667
  const inserted = [];
3001
- for (const { doc, delayMs } of sendDocs) {
3002
- if (!doc) continue;
3668
+ for (const contact of fresh) {
3669
+ const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
3003
3670
  try {
3004
3671
  await ctx.collections.sends.insertOne(doc);
3005
3672
  inserted.push({ sendId: doc._id, delayMs });
3006
3673
  } catch (err) {
3007
- if (err?.code !== 11e3) throw err;
3008
- }
3009
- }
3010
- if (inserted.length > 0) {
3011
- await Promise.all(
3012
- inserted.map(
3013
- ({ sendId, delayMs }) => ctx.queues.send.add(
3014
- "send",
3015
- { sendId: String(sendId) },
3016
- {
3017
- attempts: ctx.config.sendRetryAttempts,
3018
- backoff: { type: "exponential", delay: 6e4 },
3019
- ...delayMs > 0 ? { delay: delayMs } : {}
3020
- }
3021
- )
3022
- )
3023
- );
3024
- }
3025
- total += inserted.length;
3026
- }
3027
- if (!page.nextCursor) break;
3028
- cursor = page.nextCursor;
3029
- }
3030
- await ctx.collections.broadcasts.updateOne(
3031
- { _id: broadcast._id },
3032
- {
3033
- $set: {
3034
- status: "sent",
3035
- completedAt: /* @__PURE__ */ new Date(),
3036
- recipientCount: total,
3037
- updatedAt: /* @__PURE__ */ new Date()
3038
- }
3039
- }
3040
- );
3041
- }
3042
- function toAdapterFilter(seg) {
3043
- const out = {};
3044
- for (const f of seg.filters) {
3045
- switch (f.kind) {
3046
- case "hasTag":
3047
- out.hasTag = f.tag;
3048
- break;
3049
- case "fieldEquals":
3050
- out.fieldEquals = { field: f.field, value: f.value };
3051
- break;
3052
- case "fieldIn":
3053
- out.fieldIn = { field: f.field, values: f.values };
3054
- break;
3055
- case "fieldExists":
3056
- out.fieldExists = f.field;
3057
- break;
3058
- }
3059
- }
3060
- return out;
3061
- }
3062
- function isMailerSide(f) {
3063
- 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";
3064
- }
3065
- async function applyPostFilters(contacts, filters, ctx) {
3066
- if (filters.length === 0) return contacts;
3067
- const externalIds = contacts.map((c) => c.externalId);
3068
- const cache = {};
3069
- for (const f of filters) {
3070
- if (f.kind === "subscriptionStatus") {
3071
- const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
3072
- cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
3073
- }
3074
- if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
3075
- const query = { externalId: { $in: externalIds }, name: f.eventName };
3076
- if (f.withinDays) {
3077
- query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
3078
- }
3079
- const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
3080
- cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
3081
- }
3082
- }
3083
- return contacts.filter((c) => filters.every((f) => filterMatches(c, f, cache)));
3084
- }
3085
- function filterMatches(c, f, cache) {
3086
- switch (f.kind) {
3087
- case "subscriptionStatus":
3088
- return cache[`sub:${f.equals}`]?.has(c.externalId) ?? false;
3089
- case "firedEvent":
3090
- return cache[`evt:${f.eventName}`]?.has(c.externalId) ?? false;
3091
- case "notFiredEvent":
3092
- return !cache[`evt:${f.eventName}`]?.has(c.externalId);
3093
- case "notHasTag":
3094
- return !c.tags.includes(f.tag);
3095
- case "opened":
3096
- case "notOpened":
3097
- return true;
3098
- case "subscribedAfter":
3099
- case "subscribedBefore":
3100
- return true;
3101
- // V2
3102
- case "any":
3103
- return f.filters.some((sub) => filterMatches(c, sub, cache));
3104
- case "not":
3105
- return !filterMatches(c, f.filter, cache);
3106
- default:
3107
- return true;
3674
+ if (err?.code !== 11e3) throw err;
3675
+ }
3676
+ }
3677
+ await Promise.all(
3678
+ inserted.map(
3679
+ ({ sendId, delayMs }) => ctx.queues.send.add(
3680
+ "send",
3681
+ { sendId: String(sendId) },
3682
+ {
3683
+ attempts: ctx.config.sendRetryAttempts,
3684
+ backoff: { type: "exponential", delay: 6e4 },
3685
+ ...delayMs > 0 ? { delay: delayMs } : {}
3686
+ }
3687
+ )
3688
+ )
3689
+ );
3690
+ if (capReached) break;
3691
+ }
3692
+ const now = /* @__PURE__ */ new Date();
3693
+ const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
3694
+ const set2 = capReached ? {
3695
+ status: "paused",
3696
+ pausedAt: now,
3697
+ pauseReason: {
3698
+ code: "cap_reached",
3699
+ message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
3700
+ at: now,
3701
+ details: { recipientCap: cap, sendsSoFar: total }
3702
+ }
3703
+ } : { status: "sent", completedAt: now };
3704
+ await ctx.collections.broadcasts.updateOne(
3705
+ { _id: b._id, status: "sending", dispatchLeaseId: leaseId },
3706
+ { $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
3707
+ );
3708
+ } finally {
3709
+ await ctx.collections.broadcasts.updateOne(
3710
+ { _id: b._id, dispatchLeaseId: leaseId },
3711
+ { $set: { dispatchLeaseId: null } }
3712
+ );
3108
3713
  }
3109
3714
  }
3110
- async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
3111
- const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
3112
- if (supp.suppressed) return { doc: null, delayMs: 0 };
3715
+ function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
3113
3716
  const sendId = new mongodb.ObjectId();
3114
- const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
3115
- let delayMs = Math.max(0, scheduledMs - Date.now());
3717
+ const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
3718
+ const now = Date.now();
3719
+ let delayMs = Math.max(0, scheduledMs - now);
3116
3720
  if (respectTimezone && contact.timezone) {
3117
- const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
3118
- delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
3721
+ delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
3119
3722
  }
3120
3723
  const doc = {
3121
3724
  _id: sendId,
@@ -3150,16 +3753,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
3150
3753
  queuedAt: /* @__PURE__ */ new Date(),
3151
3754
  updatedAt: /* @__PURE__ */ new Date(),
3152
3755
  sentAt: null,
3153
- deliveredAt: null
3756
+ deliveredAt: null,
3757
+ notBefore: new Date(Date.now() + delayMs)
3154
3758
  };
3155
3759
  return { doc, delayMs };
3156
3760
  }
3761
+ var DAY_MS = 24 * 60 * 60 * 1e3;
3762
+ var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
3763
+ function recipientSlotMs(scheduledMs, timezone, nowMs) {
3764
+ let anchor = scheduledMs;
3765
+ let target = anchor + perRecipientOffsetMs(anchor, timezone);
3766
+ const earliest = nowMs - TZ_SLOT_GRACE_MS;
3767
+ if (target < earliest) {
3768
+ anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
3769
+ target = anchor + perRecipientOffsetMs(anchor, timezone);
3770
+ if (target < earliest) target += DAY_MS;
3771
+ }
3772
+ return target;
3773
+ }
3157
3774
  function perRecipientOffsetMs(scheduledMs, timezone) {
3158
- const DAY_MS2 = 24 * 60 * 60 * 1e3;
3159
3775
  try {
3160
3776
  const scheduled = new Date(scheduledMs);
3161
- const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
3162
- const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hour12: false });
3777
+ const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
3778
+ const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
3163
3779
  const parse = (s) => {
3164
3780
  const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
3165
3781
  if (!m) return 0;
@@ -3168,7 +3784,7 @@ function perRecipientOffsetMs(scheduledMs, timezone) {
3168
3784
  const utcMs = parse(utc);
3169
3785
  const localMs = parse(local);
3170
3786
  const offsetMs = utcMs - localMs;
3171
- return (offsetMs % DAY_MS2 + DAY_MS2) % DAY_MS2;
3787
+ return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
3172
3788
  } catch {
3173
3789
  return 0;
3174
3790
  }
@@ -3280,9 +3896,9 @@ async function runDnsblChecks(ctx, opts = {}) {
3280
3896
  let listedCount = 0;
3281
3897
  async function processPair(p) {
3282
3898
  const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
3283
- const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3284
- if (lookup.transient) return;
3285
- if (lookup.result === "listed") listedCount++;
3899
+ const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3900
+ if (lookup2.transient) return;
3901
+ if (lookup2.result === "listed") listedCount++;
3286
3902
  await ctx.collections.dnsblChecks.updateOne(
3287
3903
  { target: p.target, list: p.list.host },
3288
3904
  {
@@ -3291,9 +3907,9 @@ async function runDnsblChecks(ctx, opts = {}) {
3291
3907
  targetKind: p.targetKind,
3292
3908
  list: p.list.host,
3293
3909
  listLabel: p.list.label,
3294
- result: lookup.result,
3295
- returnCodes: lookup.returnCodes,
3296
- errorMessage: lookup.errorMessage,
3910
+ result: lookup2.result,
3911
+ returnCodes: lookup2.returnCodes,
3912
+ errorMessage: lookup2.errorMessage,
3297
3913
  runAt: /* @__PURE__ */ new Date()
3298
3914
  }
3299
3915
  },
@@ -3937,17 +4553,17 @@ function parseDmarcReport(xml) {
3937
4553
  const day = new Date(midMs).toISOString().slice(0, 10);
3938
4554
  for (const rec of records) {
3939
4555
  const row = rec?.row ?? {};
3940
- const count = Number(row.count ?? 0) || 0;
3941
- totalMessages += count;
4556
+ const count2 = Number(row.count ?? 0) || 0;
4557
+ totalMessages += count2;
3942
4558
  const evald = row.policy_evaluated ?? {};
3943
4559
  const dkim = evald.dkim ?? "none";
3944
4560
  const spf = evald.spf ?? "none";
3945
4561
  const aligned = dkim === "pass" || spf === "pass";
3946
4562
  if (aligned) {
3947
- passCount += count;
4563
+ passCount += count2;
3948
4564
  continue;
3949
4565
  }
3950
- failCount += count;
4566
+ failCount += count2;
3951
4567
  const sourceIp = String(row.source_ip ?? "").trim();
3952
4568
  if (!sourceIp) continue;
3953
4569
  const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
@@ -3956,7 +4572,7 @@ function parseDmarcReport(xml) {
3956
4572
  reportId,
3957
4573
  domain,
3958
4574
  sourceIp,
3959
- count,
4575
+ count: count2,
3960
4576
  headerFrom,
3961
4577
  dkimResult: dkim,
3962
4578
  spfResult: spf,
@@ -4101,11 +4717,11 @@ function suggestPolicyProgression(input) {
4101
4717
  return null;
4102
4718
  }
4103
4719
  if (currentPolicy === "quarantine") {
4104
- const pct = currentPct ?? 100;
4720
+ const pct2 = currentPct ?? 100;
4105
4721
  if (alignmentRate < 0.995) return null;
4106
- 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.` };
4107
- 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.` };
4108
- 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.` };
4722
+ 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.` };
4723
+ 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.` };
4724
+ 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.` };
4109
4725
  if (alignmentRate >= 0.999) {
4110
4726
  return { policy: "reject", pct: 100, reason: `Alignment at ${(alignmentRate * 100).toFixed(3)}% with policy=quarantine pct=100 \u2014 safe to move to p=reject.` };
4111
4727
  }
@@ -4416,6 +5032,9 @@ async function runTick(ctx) {
4416
5032
  await resumeStalledBroadcasts(ctx).catch((err) => {
4417
5033
  console.error("mailery: stalled-broadcast resume failed", err);
4418
5034
  });
5035
+ await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
5036
+ console.error("mailery: broadcast stop-rule sweep failed", err);
5037
+ });
4419
5038
  await evaluateHealth(ctx).catch((err) => {
4420
5039
  console.error("mailery: health evaluation failed", err);
4421
5040
  });
@@ -6123,7 +6742,7 @@ function humanDuration(ms) {
6123
6742
  }
6124
6743
 
6125
6744
  // src/server/runner/hygiene.ts
6126
- var DAY_MS = 864e5;
6745
+ var DAY_MS2 = 864e5;
6127
6746
  var HYGIENE_BUCKETS = [
6128
6747
  { label: "Engaged (last 30 days)", maxDays: 30 },
6129
6748
  { label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
@@ -6136,7 +6755,7 @@ async function computeListHygiene(ctx, opts = {}) {
6136
6755
  const now = opts.now ?? Date.now();
6137
6756
  const sunsetDays = opts.sunsetThresholdDays ?? 180;
6138
6757
  const recentDays = opts.recentWindowDays ?? 90;
6139
- const recentCutoff = new Date(now - recentDays * DAY_MS);
6758
+ const recentCutoff = new Date(now - recentDays * DAY_MS2);
6140
6759
  const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
6141
6760
  const subscribedIds = /* @__PURE__ */ new Set();
6142
6761
  {
@@ -6209,11 +6828,11 @@ async function computeListHygiene(ctx, opts = {}) {
6209
6828
  if (lastEngaged == null) {
6210
6829
  const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
6211
6830
  buckets[5].count++;
6212
- if (firstSent != null && now - firstSent > sunsetDays * DAY_MS) {
6831
+ if (firstSent != null && now - firstSent > sunsetDays * DAY_MS2) {
6213
6832
  isSunset = true;
6214
6833
  }
6215
6834
  } else {
6216
- const daysSince = (now - lastEngaged) / DAY_MS;
6835
+ const daysSince = (now - lastEngaged) / DAY_MS2;
6217
6836
  if (daysSince <= 30) buckets[0].count++;
6218
6837
  else if (daysSince <= 60) buckets[1].count++;
6219
6838
  else if (daysSince <= 90) buckets[2].count++;
@@ -6412,6 +7031,462 @@ async function evaluateMailTesterGate(ctx, input) {
6412
7031
  }
6413
7032
  return { allowed: true, reason: null, score: cached, code: null };
6414
7033
  }
7034
+ var BroadcastOperationError = class extends Error {
7035
+ constructor(code, message, status = 400, details) {
7036
+ super(message);
7037
+ this.code = code;
7038
+ this.status = status;
7039
+ this.details = details;
7040
+ this.name = "BroadcastOperationError";
7041
+ }
7042
+ code;
7043
+ status;
7044
+ details;
7045
+ };
7046
+ var DEFAULT_SEGMENT = {
7047
+ filters: [{ kind: "subscriptionStatus", equals: "subscribed" }]
7048
+ };
7049
+ var MAX_RECIPIENT_CAP = 1e7;
7050
+ var ORDER_FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
7051
+ var recipientCapSchema = zod.z.number().int().positive().max(MAX_RECIPIENT_CAP).nullable();
7052
+ var orderSchema = zod.z.object({
7053
+ field: zod.z.string().max(128).regex(ORDER_FIELD_RE, "must be a plain field path, e.g. updatedAt"),
7054
+ direction: zod.z.enum(["asc", "desc"])
7055
+ }).nullable();
7056
+ var pctSchema = zod.z.number().min(0).max(100);
7057
+ var stopRulesSchema = zod.z.object({
7058
+ enabled: zod.z.boolean().optional(),
7059
+ hardBounceRatePct: pctSchema.optional(),
7060
+ complaintRatePct: pctSchema.optional(),
7061
+ unsubscribeRatePct: pctSchema.optional(),
7062
+ minSample: zod.z.number().int().min(1).max(1e6).optional()
7063
+ }).strict().nullable();
7064
+ var agentCreateBroadcastSchema = zod.z.object({
7065
+ slug: slugSchema,
7066
+ name: zod.z.string().min(1).max(200),
7067
+ templateSlug: slugSchema,
7068
+ segmentDefinition: zod.z.object({ filters: zod.z.array(zod.z.any()) }).optional(),
7069
+ respectRecipientTimezone: zod.z.boolean().optional(),
7070
+ recipientCap: recipientCapSchema.optional(),
7071
+ order: orderSchema.optional(),
7072
+ stopRules: stopRulesSchema.optional()
7073
+ });
7074
+ var agentPatchBroadcastSchema = zod.z.object({
7075
+ name: zod.z.string().min(1).max(200).optional(),
7076
+ templateSlug: slugSchema.optional(),
7077
+ segmentDefinition: zod.z.object({ filters: zod.z.array(zod.z.any()) }).optional(),
7078
+ respectRecipientTimezone: zod.z.boolean().optional(),
7079
+ recipientCap: recipientCapSchema.optional(),
7080
+ order: orderSchema.optional(),
7081
+ stopRules: stopRulesSchema.optional()
7082
+ });
7083
+ var agentResumeBroadcastSchema = zod.z.object({
7084
+ recipientCap: recipientCapSchema.optional(),
7085
+ stopRules: stopRulesSchema.optional(),
7086
+ confirmedCount: zod.z.number().int().nonnegative()
7087
+ });
7088
+ var agentPauseBroadcastSchema = zod.z.object({
7089
+ reason: zod.z.string().max(500).optional()
7090
+ });
7091
+ function checkWaveSettings(mailer, input) {
7092
+ if (input.stopRules !== void 0 && !stopRulesSchema.safeParse(input.stopRules).success) {
7093
+ throw new BroadcastOperationError(
7094
+ "invalid_stop_rules",
7095
+ "stopRules must be {enabled?, hardBounceRatePct?, complaintRatePct?, unsubscribeRatePct?, minSample?} (percentages 0-100), or null"
7096
+ );
7097
+ }
7098
+ if (input.recipientCap !== void 0 && !recipientCapSchema.safeParse(input.recipientCap).success) {
7099
+ throw new BroadcastOperationError("invalid_recipient_cap", `recipientCap must be a positive integer up to ${MAX_RECIPIENT_CAP}, or null`);
7100
+ }
7101
+ if (input.order !== void 0) {
7102
+ if (!orderSchema.safeParse(input.order).success) {
7103
+ throw new BroadcastOperationError("invalid_order", 'order must be {field: "<host field path>", direction: "asc" | "desc"}, or null');
7104
+ }
7105
+ if (input.order !== null && !mailer.adapter.supportsSort) {
7106
+ throw new BroadcastOperationError(
7107
+ "adapter_cannot_sort",
7108
+ "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",
7109
+ 422
7110
+ );
7111
+ }
7112
+ }
7113
+ }
7114
+ var agentScheduleBroadcastSchema = zod.z.object({
7115
+ scheduledAt: zod.z.string().min(1),
7116
+ confirmedCount: zod.z.number().int().nonnegative(),
7117
+ respectRecipientTimezone: zod.z.boolean().optional()
7118
+ });
7119
+ function parseSegment(seg, strict) {
7120
+ const r = segmentDefinitionSchema(strict).safeParse(seg);
7121
+ if (!r.success) {
7122
+ throw new BroadcastOperationError(
7123
+ "invalid_segment",
7124
+ `segmentDefinition: ${r.error.issues.map((i) => `${i.path.join(".") || "filters"}: ${i.message}`).join("; ")}`,
7125
+ 400
7126
+ );
7127
+ }
7128
+ return r.data;
7129
+ }
7130
+ function assertSubscribedSegment(seg) {
7131
+ const filters = Array.isArray(seg?.filters) ? seg.filters : [];
7132
+ const ok = filters.some((f) => f && f.kind === "subscriptionStatus" && f.equals === "subscribed");
7133
+ if (!ok) {
7134
+ throw new BroadcastOperationError(
7135
+ "segment_requires_subscribed",
7136
+ 'the agent API only schedules broadcasts to subscribed contacts: add {"kind": "subscriptionStatus", "equals": "subscribed"} to segmentDefinition.filters (top level)',
7137
+ 422
7138
+ );
7139
+ }
7140
+ }
7141
+ async function loadBroadcast(mailer, slug) {
7142
+ const b = await mailer.collections.broadcasts.findOne({ slug });
7143
+ if (!b) throw new BroadcastOperationError("not_found", `no broadcast with slug "${slug}"`, 404);
7144
+ return b;
7145
+ }
7146
+ async function loadBroadcastTemplate(mailer, b) {
7147
+ const tpl = await mailer.collections.templates.findOne({ slug: b.templateSlug });
7148
+ if (!tpl) {
7149
+ throw new BroadcastOperationError("template_not_found", `template "${b.templateSlug}" does not exist`, 409);
7150
+ }
7151
+ return tpl;
7152
+ }
7153
+ async function countRecipients(mailer, b, segment, overrides = {}) {
7154
+ const tpl = await loadBroadcastTemplate(mailer, b);
7155
+ const count2 = await countBroadcastRecipients(
7156
+ {
7157
+ _id: b._id,
7158
+ segmentDefinition: segment ?? b.segmentDefinition,
7159
+ order: b.order ?? null,
7160
+ recipientCap: overrides.recipientCap !== void 0 ? overrides.recipientCap : b.recipientCap ?? null
7161
+ },
7162
+ tpl.kind,
7163
+ mailer.getRunnerContext()
7164
+ );
7165
+ const heldSends = b._id ? await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: "held" }) : 0;
7166
+ return { ...count2, heldSends, templateKind: tpl.kind };
7167
+ }
7168
+ function stopRuleStatus(mailer, b, stats) {
7169
+ return evaluateStopRules(stats, effectiveStopRules(mailer.config, b));
7170
+ }
7171
+ async function pauseBroadcastByOperator(mailer, slug, input, actor) {
7172
+ const b = await loadBroadcast(mailer, slug);
7173
+ if (b.status !== "sending" && b.status !== "sent") {
7174
+ 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);
7175
+ }
7176
+ const out = await pauseBroadcast(
7177
+ mailer.getRunnerContext(),
7178
+ b._id,
7179
+ { code: "manual", message: input.reason?.trim() || `paused by ${actor}`, at: /* @__PURE__ */ new Date(), details: { actor } },
7180
+ actor
7181
+ );
7182
+ if (!out.paused) throw new BroadcastOperationError("not_pausable", "broadcast changed state while being paused", 409);
7183
+ return { broadcast: await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? b, heldSends: out.heldSends };
7184
+ }
7185
+ async function createBroadcast(mailer, input, actor, opts = {}) {
7186
+ const { slug, name, templateSlug } = input;
7187
+ if (!slug || !name || !templateSlug) {
7188
+ throw new BroadcastOperationError("validation_failed", "slug, name, templateSlug required");
7189
+ }
7190
+ const segmentDefinition = input.segmentDefinition ? parseSegment(input.segmentDefinition, !!opts.strictSegment) : DEFAULT_SEGMENT;
7191
+ if (opts.requireSubscribed) assertSubscribedSegment(segmentDefinition);
7192
+ checkWaveSettings(mailer, input);
7193
+ await assertBroadcastTemplate(mailer, templateSlug, { mustExist: false });
7194
+ const now = /* @__PURE__ */ new Date();
7195
+ const doc = {
7196
+ slug,
7197
+ name,
7198
+ templateSlug,
7199
+ segmentDefinition,
7200
+ status: "draft",
7201
+ scheduledAt: null,
7202
+ startedAt: null,
7203
+ completedAt: null,
7204
+ confirmationRequired: true,
7205
+ confirmedCount: null,
7206
+ confirmedAt: null,
7207
+ confirmedBy: null,
7208
+ recipientCount: null,
7209
+ stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
7210
+ createdAt: now,
7211
+ createdBy: actor,
7212
+ updatedAt: now
7213
+ };
7214
+ if (input.respectRecipientTimezone) doc.respectRecipientTimezone = true;
7215
+ if (input.recipientCap !== void 0) doc.recipientCap = input.recipientCap;
7216
+ if (input.order !== void 0) doc.order = input.order;
7217
+ if (input.stopRules !== void 0) doc.stopRules = input.stopRules;
7218
+ try {
7219
+ const res = await mailer.collections.broadcasts.insertOne(doc);
7220
+ doc._id = res.insertedId;
7221
+ } catch (err) {
7222
+ if (err?.code === 11e3) throw new BroadcastOperationError("slug_taken", `slug "${slug}" is taken`, 409);
7223
+ throw err;
7224
+ }
7225
+ await mailer.audit({
7226
+ actor,
7227
+ action: "broadcast.create",
7228
+ resource: { collection: "mailer_broadcasts", id: doc._id, slug }
7229
+ });
7230
+ return doc;
7231
+ }
7232
+ async function patchBroadcast(mailer, slug, patch, actor, opts = {}) {
7233
+ const b = await loadBroadcast(mailer, slug);
7234
+ if (b.status !== "draft") {
7235
+ throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be edited`, 409);
7236
+ }
7237
+ const segmentDefinition = patch.segmentDefinition ? parseSegment(patch.segmentDefinition, !!opts.strictSegment) : void 0;
7238
+ if (opts.requireSubscribed && segmentDefinition) assertSubscribedSegment(segmentDefinition);
7239
+ checkWaveSettings(mailer, patch);
7240
+ if (typeof patch.templateSlug === "string") await assertBroadcastTemplate(mailer, patch.templateSlug, { mustExist: false });
7241
+ const set2 = { updatedAt: /* @__PURE__ */ new Date() };
7242
+ if (patch.recipientCap !== void 0) set2.recipientCap = patch.recipientCap;
7243
+ if (patch.order !== void 0) set2.order = patch.order;
7244
+ if (patch.stopRules !== void 0) set2.stopRules = patch.stopRules;
7245
+ if (typeof patch.name === "string") set2.name = patch.name;
7246
+ if (typeof patch.templateSlug === "string") set2.templateSlug = patch.templateSlug;
7247
+ if (segmentDefinition) set2.segmentDefinition = segmentDefinition;
7248
+ if (typeof patch.respectRecipientTimezone === "boolean") set2.respectRecipientTimezone = patch.respectRecipientTimezone;
7249
+ const res = await mailer.collections.broadcasts.findOneAndUpdate(
7250
+ { _id: b._id, status: "draft" },
7251
+ { $set: set2 },
7252
+ { returnDocument: "after" }
7253
+ );
7254
+ if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being edited", 409);
7255
+ await mailer.audit({
7256
+ actor,
7257
+ action: "broadcast.update",
7258
+ resource: { collection: "mailer_broadcasts", id: b._id, slug },
7259
+ diffSummary: Object.keys(set2).filter((k) => k !== "updatedAt").join(", ")
7260
+ });
7261
+ return res;
7262
+ }
7263
+ async function scheduleBroadcast(mailer, slug, input, actor, opts = {}) {
7264
+ const b = await loadBroadcast(mailer, slug);
7265
+ if (b.status !== "draft") {
7266
+ throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be scheduled`, 409);
7267
+ }
7268
+ if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
7269
+ parseSegment(b.segmentDefinition, true);
7270
+ await assertBroadcastTemplate(mailer, b.templateSlug, { mustExist: true });
7271
+ if (!input.scheduledAt) throw new BroadcastOperationError("scheduledAt_required", "scheduledAt is required");
7272
+ const scheduled = new Date(input.scheduledAt);
7273
+ if (Number.isNaN(scheduled.getTime())) throw new BroadcastOperationError("bad_scheduledAt", "scheduledAt is not a date");
7274
+ if (typeof input.confirmedCount !== "number") {
7275
+ throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
7276
+ }
7277
+ const confirmedCount = input.confirmedCount;
7278
+ const threshold = mailer.config.broadcastConfirmationThreshold;
7279
+ if (opts.requireExactCount) {
7280
+ const { recipientCount } = await countRecipients(mailer, b);
7281
+ if (confirmedCount !== recipientCount) {
7282
+ throw new BroadcastOperationError(
7283
+ "count_mismatch",
7284
+ `confirmedCount ${confirmedCount} does not match the ${recipientCount} recipient(s) this broadcast would send to now; recount with POST /broadcasts/${b.slug}/count`,
7285
+ 409,
7286
+ { expected: recipientCount, confirmedCount }
7287
+ );
7288
+ }
7289
+ }
7290
+ const set2 = {
7291
+ status: "scheduled",
7292
+ scheduledAt: scheduled,
7293
+ confirmedCount,
7294
+ confirmedAt: /* @__PURE__ */ new Date(),
7295
+ confirmedBy: actor,
7296
+ updatedAt: /* @__PURE__ */ new Date()
7297
+ };
7298
+ if (input.respectRecipientTimezone) set2.respectRecipientTimezone = true;
7299
+ const res = await mailer.collections.broadcasts.findOneAndUpdate(
7300
+ { _id: b._id, status: "draft" },
7301
+ { $set: set2 },
7302
+ { returnDocument: "after" }
7303
+ );
7304
+ if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being scheduled", 409);
7305
+ await mailer.audit({
7306
+ actor,
7307
+ action: "broadcast.schedule",
7308
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7309
+ diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
7310
+ });
7311
+ return res;
7312
+ }
7313
+ async function resumeBroadcast(mailer, slug, input, actor, opts = {}) {
7314
+ const b = await loadBroadcast(mailer, slug);
7315
+ if (b.status !== "paused") {
7316
+ throw new BroadcastOperationError("not_paused", `broadcast is ${b.status}; only a paused broadcast can be resumed`, 409);
7317
+ }
7318
+ if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
7319
+ checkWaveSettings(mailer, { recipientCap: input.recipientCap, stopRules: input.stopRules });
7320
+ const cap = input.recipientCap !== void 0 ? input.recipientCap : b.recipientCap ?? null;
7321
+ const stopRules = input.stopRules === void 0 ? b.stopRules ?? null : input.stopRules === null ? null : { ...b.stopRules ?? {}, ...input.stopRules };
7322
+ if (b.pauseReason?.code === "stop_rule") {
7323
+ const evaluation = evaluateStopRules(
7324
+ await broadcastStatsFor(mailer.collections, b._id),
7325
+ effectiveStopRules(mailer.config, { stopRules })
7326
+ );
7327
+ if (evaluation.breaches.length > 0) {
7328
+ throw new BroadcastOperationError(
7329
+ "stop_rule_still_breached",
7330
+ `the stop rules still fire (${evaluation.breaches.map((x) => `${x.rule} ${x.ratePct}% > ${x.thresholdPct}%`).join(", ")}); investigate, then resume with adjusted stopRules to override`,
7331
+ 409,
7332
+ { evaluation }
7333
+ );
7334
+ }
7335
+ }
7336
+ if (b.pauseReason?.code === "circuit_breaker") {
7337
+ const tpl = await loadBroadcastTemplate(mailer, b);
7338
+ const bucket = await getBucketStatus(mailer.getRunnerContext(), tpl.fromEmail, tpl.kind);
7339
+ if (bucket?.status === "tripped") {
7340
+ throw new BroadcastOperationError(
7341
+ "circuit_breaker_tripped",
7342
+ `the ${bucket.senderDomain ?? "sender"} ${tpl.kind} circuit breaker is still tripped (${bucket.trippedReason ?? "no reason recorded"}); resume it first (POST /api/health/resume)`,
7343
+ 409,
7344
+ { bucket: bucket._id, trippedReason: bucket.trippedReason }
7345
+ );
7346
+ }
7347
+ }
7348
+ const sendsSoFar = await mailer.collections.sends.countDocuments({ broadcastId: b._id });
7349
+ if (cap !== null && cap <= sendsSoFar && b.pauseReason?.code === "cap_reached") {
7350
+ throw new BroadcastOperationError(
7351
+ "cap_not_raised",
7352
+ `${sendsSoFar} send(s) already count against recipientCap ${cap}; pass a higher recipientCap (or null for no cap) to send the next wave`,
7353
+ 409,
7354
+ { recipientCap: cap, sendsSoFar }
7355
+ );
7356
+ }
7357
+ if (cap !== null && cap < sendsSoFar) {
7358
+ throw new BroadcastOperationError("cap_below_sent", `recipientCap ${cap} is below the ${sendsSoFar} send(s) already made`, 400);
7359
+ }
7360
+ let expected = null;
7361
+ if (opts.requireExactCount) {
7362
+ if (typeof input.confirmedCount !== "number") {
7363
+ throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
7364
+ }
7365
+ const count2 = await countRecipients(mailer, b, void 0, { recipientCap: cap });
7366
+ expected = count2.recipientCount + count2.heldSends;
7367
+ if (input.confirmedCount !== expected) {
7368
+ throw new BroadcastOperationError(
7369
+ "count_mismatch",
7370
+ `confirmedCount ${input.confirmedCount} does not match the ${expected} send(s) resuming would release now (${count2.recipientCount} new + ${count2.heldSends} held)`,
7371
+ 409,
7372
+ { expected, recipientCount: count2.recipientCount, heldSends: count2.heldSends, confirmedCount: input.confirmedCount }
7373
+ );
7374
+ }
7375
+ }
7376
+ const now = /* @__PURE__ */ new Date();
7377
+ const resumed = await mailer.collections.broadcasts.findOneAndUpdate(
7378
+ { _id: b._id, status: "paused" },
7379
+ {
7380
+ $set: {
7381
+ status: "sending",
7382
+ recipientCap: cap,
7383
+ stopRules,
7384
+ pausedAt: null,
7385
+ pauseReason: null,
7386
+ dispatchLeaseId: null,
7387
+ updatedAt: now,
7388
+ ...typeof input.confirmedCount === "number" ? { confirmedCount: input.confirmedCount, confirmedAt: now, confirmedBy: actor } : {}
7389
+ }
7390
+ },
7391
+ { returnDocument: "after" }
7392
+ );
7393
+ if (!resumed) throw new BroadcastOperationError("not_paused", "broadcast left paused while being resumed", 409);
7394
+ await mailer.audit({
7395
+ actor,
7396
+ action: "broadcast.resume",
7397
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7398
+ diffSummary: `was paused (${b.pauseReason?.code ?? "unknown"}) \xB7 recipientCap ${b.recipientCap ?? "none"} \u2192 ${cap ?? "none"} \xB7 sendsSoFar=${sendsSoFar}${expected !== null ? ` \xB7 confirmedCount=${expected}` : ""}`
7399
+ });
7400
+ const ctx = mailer.getRunnerContext();
7401
+ await releaseHeldSends(ctx, b._id);
7402
+ await startBroadcastDispatch(resumed, ctx);
7403
+ return await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? resumed;
7404
+ }
7405
+ async function cancelBroadcast(mailer, slug, actor) {
7406
+ const b = await loadBroadcast(mailer, slug);
7407
+ if (b.status === "cancelled") return { broadcast: b, cancelledSends: 0 };
7408
+ const pending = await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: { $in: ["queued", "held"] } });
7409
+ if ((b.status === "sent" || b.status === "failed") && pending === 0) {
7410
+ throw new BroadcastOperationError("already_finished", `broadcast is ${b.status} with nothing left to send; there is nothing to cancel`, 409);
7411
+ }
7412
+ await mailer.collections.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
7413
+ const now = /* @__PURE__ */ new Date();
7414
+ const cancelled = await mailer.collections.sends.updateMany(
7415
+ { broadcastId: b._id, status: { $in: ["queued", "held"] } },
7416
+ { $set: { status: "cancelled", errorMessage: `cancelled: broadcast cancelled by ${actor}`, updatedAt: now } }
7417
+ );
7418
+ await mailer.audit({
7419
+ actor,
7420
+ action: "broadcast.cancel",
7421
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
7422
+ diffSummary: `was ${b.status}; cancelled ${cancelled.modifiedCount} queued/held send(s)`
7423
+ });
7424
+ const after = await mailer.collections.broadcasts.findOne({ _id: b._id });
7425
+ return { broadcast: after ?? b, cancelledSends: cancelled.modifiedCount };
7426
+ }
7427
+ async function assertBroadcastTemplate(mailer, templateSlug, opts) {
7428
+ const tpl = await mailer.collections.templates.findOne({ slug: templateSlug });
7429
+ if (!tpl) {
7430
+ if (opts.mustExist) throw new BroadcastOperationError("template_not_found", `template "${templateSlug}" does not exist`, 409);
7431
+ return;
7432
+ }
7433
+ if (tpl.kind !== "marketing") {
7434
+ throw new BroadcastOperationError(
7435
+ "template_not_marketing",
7436
+ `template "${templateSlug}" is ${tpl.kind}; a broadcast needs a marketing template (List-Unsubscribe, marketing opt-outs, circuit breaker)`,
7437
+ 409
7438
+ );
7439
+ }
7440
+ if (opts.mustExist && !tpl.body?.html && !tpl.body?.mjml) {
7441
+ throw new BroadcastOperationError("template_not_published", `template "${templateSlug}" has no published body`, 409);
7442
+ }
7443
+ }
7444
+ async function computeBroadcastStats(mailer, idFilter) {
7445
+ return aggregateBroadcastStats(mailer.collections, idFilter);
7446
+ }
7447
+ function capProgress(b, stats) {
7448
+ const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
7449
+ return {
7450
+ recipientCap: cap,
7451
+ sendsSoFar: stats.total,
7452
+ remaining: cap === null ? null : Math.max(0, cap - stats.total)
7453
+ };
7454
+ }
7455
+ async function broadcastStatusBreakdown(mailer, broadcastId) {
7456
+ const rows = await mailer.collections.sends.aggregate([
7457
+ { $match: { broadcastId } },
7458
+ { $group: { _id: "$status", n: { $sum: 1 } } }
7459
+ ]).toArray();
7460
+ return Object.fromEntries(rows.map((r) => [r._id, r.n]));
7461
+ }
7462
+ function broadcastSummary(b) {
7463
+ return {
7464
+ id: String(b._id),
7465
+ slug: b.slug,
7466
+ name: b.name,
7467
+ templateSlug: b.templateSlug,
7468
+ status: b.status,
7469
+ segmentDefinition: b.segmentDefinition,
7470
+ respectRecipientTimezone: b.respectRecipientTimezone === true,
7471
+ recipientCap: b.recipientCap ?? null,
7472
+ order: b.order ?? null,
7473
+ pausedAt: b.pausedAt ?? null,
7474
+ pauseReason: b.pauseReason ?? null,
7475
+ stopRules: b.stopRules ?? null,
7476
+ stopRuleBreach: b.stopRuleBreach ?? null,
7477
+ failureReason: b.failureReason ?? null,
7478
+ scheduledAt: b.scheduledAt,
7479
+ startedAt: b.startedAt,
7480
+ completedAt: b.completedAt,
7481
+ confirmedCount: b.confirmedCount,
7482
+ confirmedAt: b.confirmedAt,
7483
+ confirmedBy: b.confirmedBy,
7484
+ recipientCount: b.recipientCount,
7485
+ createdAt: b.createdAt,
7486
+ createdBy: b.createdBy,
7487
+ updatedAt: b.updatedAt
7488
+ };
7489
+ }
6415
7490
 
6416
7491
  // src/server/api/admin.ts
6417
7492
  var __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
@@ -7214,19 +8289,19 @@ function createAdminApiRouter(mailer, opts = {}) {
7214
8289
  const flow = await c.flows.findOne({ slug: req.params.slug });
7215
8290
  if (!flow) return res.status(404).json({ error: "not_found" });
7216
8291
  const { steps, notes, trigger, name, description, goal, audience } = req.body ?? {};
7217
- const set = {
8292
+ const set2 = {
7218
8293
  "draft.lastModifiedBy": req.actor,
7219
8294
  "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
7220
8295
  updatedAt: /* @__PURE__ */ new Date()
7221
8296
  };
7222
- if (Array.isArray(steps)) set["draft.steps"] = steps;
7223
- if (typeof notes === "string") set["draft.notes"] = notes;
7224
- if (trigger?.eventName) set.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
7225
- if (typeof name === "string") set.name = name;
7226
- if (typeof description === "string") set.description = description;
7227
- if (typeof goal === "string") set.goal = goal;
7228
- if (typeof audience === "string") set.audience = audience;
7229
- await c.flows.updateOne({ _id: flow._id }, { $set: set });
8297
+ if (Array.isArray(steps)) set2["draft.steps"] = steps;
8298
+ if (typeof notes === "string") set2["draft.notes"] = notes;
8299
+ if (trigger?.eventName) set2.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
8300
+ if (typeof name === "string") set2.name = name;
8301
+ if (typeof description === "string") set2.description = description;
8302
+ if (typeof goal === "string") set2.goal = goal;
8303
+ if (typeof audience === "string") set2.audience = audience;
8304
+ await c.flows.updateOne({ _id: flow._id }, { $set: set2 });
7230
8305
  await mailer.audit({
7231
8306
  actor: req.actor,
7232
8307
  action: "flow.draft.update",
@@ -7387,25 +8462,25 @@ function createAdminApiRouter(mailer, opts = {}) {
7387
8462
  }
7388
8463
  );
7389
8464
  }
7390
- const set = {
8465
+ const set2 = {
7391
8466
  "draft.lastModifiedBy": req.actor,
7392
8467
  "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
7393
8468
  updatedAt: /* @__PURE__ */ new Date()
7394
8469
  };
7395
- if (typeof subject === "string") set["draft.subject"] = subject;
7396
- if (typeof preheader === "string") set["draft.preheader"] = preheader;
7397
- if (typeof mjml === "string") set["draft.mjml"] = mjml;
7398
- if (typeof html === "string") set["draft.html"] = html;
7399
- if (editorJson !== void 0) set["draft.editorJson"] = editorJson;
7400
- if (typeof notes === "string") set["draft.notes"] = notes;
7401
- if (typeof name === "string") set.name = name;
7402
- if (typeof fromName === "string") set.fromName = fromName;
7403
- if (typeof fromEmail === "string") set.fromEmail = fromEmail;
7404
- if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
7405
- if (kind === "marketing" || kind === "transactional") set.kind = kind;
8470
+ if (typeof subject === "string") set2["draft.subject"] = subject;
8471
+ if (typeof preheader === "string") set2["draft.preheader"] = preheader;
8472
+ if (typeof mjml === "string") set2["draft.mjml"] = mjml;
8473
+ if (typeof html === "string") set2["draft.html"] = html;
8474
+ if (editorJson !== void 0) set2["draft.editorJson"] = editorJson;
8475
+ if (typeof notes === "string") set2["draft.notes"] = notes;
8476
+ if (typeof name === "string") set2.name = name;
8477
+ if (typeof fromName === "string") set2.fromName = fromName;
8478
+ if (typeof fromEmail === "string") set2.fromEmail = fromEmail;
8479
+ if (typeof replyTo === "string" || replyTo === null) set2.replyTo = replyTo;
8480
+ if (kind === "marketing" || kind === "transactional") set2.kind = kind;
7406
8481
  if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
7407
- const resultingKind = set.kind ?? tpl.kind;
7408
- const resultingFromEmail = set.fromEmail ?? tpl.fromEmail;
8482
+ const resultingKind = set2.kind ?? tpl.kind;
8483
+ const resultingFromEmail = set2.fromEmail ?? tpl.fromEmail;
7409
8484
  const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
7410
8485
  if (!senderCheck.ok) {
7411
8486
  return res.status(400).json({
@@ -7415,10 +8490,10 @@ function createAdminApiRouter(mailer, opts = {}) {
7415
8490
  });
7416
8491
  }
7417
8492
  }
7418
- if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set.bodyFormat = bodyFormat;
7419
- if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
7420
- if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
7421
- await c.templates.updateOne({ _id: tpl._id }, { $set: set });
8493
+ if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set2.bodyFormat = bodyFormat;
8494
+ if (typeof trackOpens === "boolean") set2.trackOpens = trackOpens;
8495
+ if (typeof trackClicks === "boolean") set2.trackClicks = trackClicks;
8496
+ await c.templates.updateOne({ _id: tpl._id }, { $set: set2 });
7422
8497
  await mailer.audit({
7423
8498
  actor: req.actor,
7424
8499
  action: "template.draft.update",
@@ -7896,125 +8971,60 @@ function createAdminApiRouter(mailer, opts = {}) {
7896
8971
  );
7897
8972
  r.post(
7898
8973
  "/broadcasts",
7899
- asyncHandler(async (req, res) => {
7900
- const { slug, name, templateSlug, segmentDefinition } = req.body ?? {};
7901
- if (!slug || !name || !templateSlug) {
7902
- return res.status(400).json({ error: "validation_failed", message: "slug, name, templateSlug required" });
7903
- }
7904
- const now = /* @__PURE__ */ new Date();
7905
- try {
7906
- await c.broadcasts.insertOne({
7907
- slug,
7908
- name,
7909
- templateSlug,
7910
- segmentDefinition: segmentDefinition ?? { filters: [{ kind: "subscriptionStatus", equals: "subscribed" }] },
7911
- status: "draft",
7912
- scheduledAt: null,
7913
- startedAt: null,
7914
- completedAt: null,
7915
- confirmationRequired: true,
7916
- confirmedCount: null,
7917
- confirmedAt: null,
7918
- confirmedBy: null,
7919
- recipientCount: null,
7920
- stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
7921
- createdAt: now,
7922
- createdBy: req.actor,
7923
- updatedAt: now
7924
- });
7925
- } catch (err) {
7926
- if (err?.code === 11e3) return res.status(409).json({ error: "slug_taken" });
7927
- throw err;
7928
- }
7929
- await mailer.audit({
7930
- actor: req.actor,
7931
- action: "broadcast.create",
7932
- resource: { collection: "mailer_broadcasts", slug }
7933
- });
8974
+ broadcastHandler(async (req, res) => {
8975
+ const { slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
8976
+ await createBroadcast(
8977
+ mailer,
8978
+ { slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
8979
+ req.actor
8980
+ );
7934
8981
  return res.json({ ok: true, slug });
7935
8982
  })
7936
8983
  );
7937
8984
  r.patch(
7938
8985
  "/broadcasts/:slug",
7939
- asyncHandler(async (req, res) => {
7940
- const b = await c.broadcasts.findOne({ slug: req.params.slug });
7941
- if (!b) return res.status(404).json({ error: "not_found" });
7942
- if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
7943
- const { name, templateSlug, segmentDefinition } = req.body ?? {};
7944
- const set = { updatedAt: /* @__PURE__ */ new Date() };
7945
- if (typeof name === "string") set.name = name;
7946
- if (typeof templateSlug === "string") set.templateSlug = templateSlug;
7947
- if (segmentDefinition) set.segmentDefinition = segmentDefinition;
7948
- await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
8986
+ broadcastHandler(async (req, res) => {
8987
+ const { name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
8988
+ await patchBroadcast(
8989
+ mailer,
8990
+ String(req.params.slug),
8991
+ { name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
8992
+ req.actor
8993
+ );
7949
8994
  return res.json({ ok: true });
7950
8995
  })
7951
8996
  );
7952
8997
  r.post(
7953
8998
  "/broadcasts/:slug/segment/count",
7954
- asyncHandler(async (req, res) => {
7955
- const segmentDefinition = req.body?.segmentDefinition;
7956
- if (!segmentDefinition?.filters) return res.status(400).json({ error: "segment_required" });
7957
- const t0 = Date.now();
7958
- const hostFilter = {};
7959
- for (const f of segmentDefinition.filters) {
7960
- if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
7961
- if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
7962
- }
7963
- const hasMailerFilters = segmentDefinition.filters.some(
7964
- (f) => ["subscriptionStatus", "firedEvent", "notFiredEvent", "notHasTag", "opened", "notOpened", "subscribedAfter", "subscribedBefore"].includes(f.kind)
7965
- );
7966
- const upperBound = await mailer.adapter.count(hostFilter);
8999
+ broadcastHandler(async (req, res) => {
9000
+ const raw = req.body?.segmentDefinition;
9001
+ if (!raw?.filters) return res.status(400).json({ error: "segment_required" });
9002
+ const segment = parseSegment(raw, false);
9003
+ const b = await loadBroadcast(mailer, String(req.params.slug));
9004
+ const count2 = await countRecipients(mailer, b, segment);
7967
9005
  return res.json({
7968
- upperBound,
7969
- approximate: hasMailerFilters,
7970
- computedMs: Date.now() - t0
9006
+ upperBound: count2.recipientCount,
9007
+ approximate: false,
9008
+ computedMs: count2.computedMs,
9009
+ hostMatched: count2.hostMatched,
9010
+ eligible: count2.eligible,
9011
+ alreadySent: count2.alreadySent,
9012
+ recipientCount: count2.recipientCount
7971
9013
  });
7972
9014
  })
7973
9015
  );
7974
9016
  r.post(
7975
9017
  "/broadcasts/:slug/schedule",
7976
- asyncHandler(async (req, res) => {
7977
- const b = await c.broadcasts.findOne({ slug: req.params.slug });
7978
- if (!b) return res.status(404).json({ error: "not_found" });
7979
- if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
9018
+ broadcastHandler(async (req, res) => {
7980
9019
  const { scheduledAt, confirmedCount, respectRecipientTimezone } = req.body ?? {};
7981
- if (!scheduledAt) return res.status(400).json({ error: "scheduledAt_required" });
7982
- const scheduled = new Date(scheduledAt);
7983
- if (Number.isNaN(scheduled.getTime())) return res.status(400).json({ error: "bad_scheduledAt" });
7984
- const threshold = mailer.config.broadcastConfirmationThreshold;
7985
- if (typeof confirmedCount !== "number") {
7986
- return res.status(400).json({ error: "confirmedCount_required" });
7987
- }
7988
- const set = {
7989
- status: "scheduled",
7990
- scheduledAt: scheduled,
7991
- confirmedCount,
7992
- confirmedAt: /* @__PURE__ */ new Date(),
7993
- confirmedBy: req.actor,
7994
- updatedAt: /* @__PURE__ */ new Date()
7995
- };
7996
- if (respectRecipientTimezone) set.respectRecipientTimezone = true;
7997
- await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
7998
- await mailer.audit({
7999
- actor: req.actor,
8000
- action: "broadcast.schedule",
8001
- resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
8002
- diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
8003
- });
9020
+ await scheduleBroadcast(mailer, String(req.params.slug), { scheduledAt, confirmedCount, respectRecipientTimezone }, req.actor);
8004
9021
  return res.json({ ok: true });
8005
9022
  })
8006
9023
  );
8007
9024
  r.post(
8008
9025
  "/broadcasts/:slug/cancel",
8009
- asyncHandler(async (req, res) => {
8010
- const b = await c.broadcasts.findOne({ slug: req.params.slug });
8011
- if (!b) return res.status(404).json({ error: "not_found" });
8012
- await c.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
8013
- await mailer.audit({
8014
- actor: req.actor,
8015
- action: "broadcast.cancel",
8016
- resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug }
8017
- });
9026
+ broadcastHandler(async (req, res) => {
9027
+ await cancelBroadcast(mailer, String(req.params.slug), req.actor);
8018
9028
  return res.json({ ok: true });
8019
9029
  })
8020
9030
  );
@@ -8151,35 +9161,15 @@ async function computeTemplateStats(mailer, slugFilter) {
8151
9161
  }
8152
9162
  return out;
8153
9163
  }
8154
- function emptyBroadcastStats() {
8155
- return { delivered: 0, opened: 0, clicked: 0, bounced: 0 };
8156
- }
8157
- async function computeBroadcastStats(mailer, idFilter) {
8158
- const out = /* @__PURE__ */ new Map();
8159
- const match = { broadcastId: { $ne: null } };
8160
- if (idFilter) match.broadcastId = idFilter;
8161
- const rows = await mailer.collections.sends.aggregate([
8162
- { $match: match },
8163
- {
8164
- $group: {
8165
- _id: "$broadcastId",
8166
- delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } },
8167
- opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
8168
- clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
8169
- bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } }
9164
+ function broadcastHandler(fn) {
9165
+ return (req, res, next) => {
9166
+ fn(req, res, next).catch((err) => {
9167
+ if (err instanceof BroadcastOperationError) {
9168
+ return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
8170
9169
  }
8171
- }
8172
- ]).toArray();
8173
- for (const row of rows) {
8174
- if (!row._id) continue;
8175
- out.set(String(row._id), {
8176
- delivered: row.delivered,
8177
- opened: row.opened,
8178
- clicked: row.clicked,
8179
- bounced: row.bounced
9170
+ next(err);
8180
9171
  });
8181
- }
8182
- return out;
9172
+ };
8183
9173
  }
8184
9174
 
8185
9175
  // src/server/api/wrap.ts
@@ -8556,7 +9546,16 @@ function unitToMs2(value, unit) {
8556
9546
  }
8557
9547
 
8558
9548
  // src/server/api/agent.ts
8559
- var VERSION = "0.17.1" ;
9549
+ var VERSION = "0.18.0" ;
9550
+ var broadcastCountSchema = zod.z.object({
9551
+ recipientCap: zod.z.number().int().positive().max(1e7).nullable().optional()
9552
+ });
9553
+ var broadcastTestSendSchema = zod.z.object({
9554
+ contactIds: zod.z.array(zod.z.string().min(1).max(256)).min(1).max(10),
9555
+ vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
9556
+ /** 'queue' leaves the sends queued for the worker instead of dispatching inline. */
9557
+ dispatch: zod.z.enum(["now", "queue"]).optional()
9558
+ });
8560
9559
  var agentTagsInputSchema = zod.z.object({
8561
9560
  add: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([]),
8562
9561
  remove: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([])
@@ -8821,7 +9820,7 @@ function createAgentRouter(mailer, opts) {
8821
9820
  }
8822
9821
  const now = /* @__PURE__ */ new Date();
8823
9822
  const publishedBy = input.publishedBy ?? actorOf(req);
8824
- const set = {
9823
+ const set2 = {
8825
9824
  slug,
8826
9825
  name: input.name,
8827
9826
  description: input.description,
@@ -8852,7 +9851,7 @@ function createAgentRouter(mailer, opts) {
8852
9851
  const result = await c.templates.updateOne(
8853
9852
  { slug },
8854
9853
  {
8855
- $set: set,
9854
+ $set: set2,
8856
9855
  $setOnInsert: {
8857
9856
  createdAt: now,
8858
9857
  stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null }
@@ -9234,6 +10233,152 @@ function createAgentRouter(mailer, opts) {
9234
10233
  res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
9235
10234
  })
9236
10235
  );
10236
+ router.get(
10237
+ "/broadcasts",
10238
+ wrap2(async (_req, res) => {
10239
+ const docs = await c.broadcasts.find().sort({ createdAt: -1 }).limit(200).toArray();
10240
+ const stats = await computeBroadcastStats(mailer);
10241
+ res.json(docs.map((b) => ({ ...broadcastSummary(b), stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
10242
+ })
10243
+ );
10244
+ router.get(
10245
+ "/broadcasts/:slug",
10246
+ wrap2(async (req, res) => {
10247
+ const b = await loadBroadcast(mailer, String(req.params.slug));
10248
+ const [statsMap, statusBreakdown] = await Promise.all([
10249
+ computeBroadcastStats(mailer, b._id),
10250
+ broadcastStatusBreakdown(mailer, b._id)
10251
+ ]);
10252
+ const stats = statsMap.get(String(b._id)) ?? emptyBroadcastStats();
10253
+ res.json({
10254
+ broadcast: broadcastSummary(b),
10255
+ stats,
10256
+ statusBreakdown,
10257
+ capProgress: capProgress(b, stats),
10258
+ stopRules: stopRuleStatus(mailer, b, stats)
10259
+ });
10260
+ })
10261
+ );
10262
+ router.post(
10263
+ "/broadcasts",
10264
+ wrap2(async (req, res) => {
10265
+ const parsed = agentCreateBroadcastSchema.safeParse(req.body ?? {});
10266
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10267
+ const b = await createBroadcast(mailer, parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
10268
+ res.status(201).json({ broadcast: broadcastSummary(b) });
10269
+ })
10270
+ );
10271
+ router.patch(
10272
+ "/broadcasts/:slug",
10273
+ wrap2(async (req, res) => {
10274
+ const parsed = agentPatchBroadcastSchema.safeParse(req.body ?? {});
10275
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10276
+ const b = await patchBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
10277
+ res.json({ broadcast: broadcastSummary(b) });
10278
+ })
10279
+ );
10280
+ router.post(
10281
+ "/broadcasts/:slug/schedule",
10282
+ wrap2(async (req, res) => {
10283
+ const parsed = agentScheduleBroadcastSchema.safeParse(req.body ?? {});
10284
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10285
+ const b = await scheduleBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
10286
+ requireSubscribed: true,
10287
+ requireExactCount: true
10288
+ });
10289
+ res.json({ broadcast: broadcastSummary(b) });
10290
+ })
10291
+ );
10292
+ router.post(
10293
+ "/broadcasts/:slug/count",
10294
+ wrap2(async (req, res) => {
10295
+ const parsed = broadcastCountSchema.safeParse(req.body ?? {});
10296
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10297
+ const b = await loadBroadcast(mailer, String(req.params.slug));
10298
+ const count2 = await countRecipients(
10299
+ mailer,
10300
+ b,
10301
+ void 0,
10302
+ parsed.data.recipientCap !== void 0 ? { recipientCap: parsed.data.recipientCap } : {}
10303
+ );
10304
+ res.json({
10305
+ slug: b.slug,
10306
+ status: b.status,
10307
+ ...count2,
10308
+ confirmationThreshold: mailer.config.broadcastConfirmationThreshold
10309
+ });
10310
+ })
10311
+ );
10312
+ router.post(
10313
+ "/broadcasts/:slug/test-send",
10314
+ wrap2(async (req, res) => {
10315
+ const parsed = broadcastTestSendSchema.safeParse(req.body ?? {});
10316
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10317
+ const b = await loadBroadcast(mailer, String(req.params.slug));
10318
+ const tpl = await loadBroadcastTemplate(mailer, b);
10319
+ if (!tpl.body?.html && !tpl.body?.mjml) {
10320
+ return res.status(409).json({ error: "not_published", message: `template "${tpl.slug}" has no published body` });
10321
+ }
10322
+ const contacts = [];
10323
+ for (const id of parsed.data.contactIds) {
10324
+ const contact = await loadContact(res, id);
10325
+ if (!contact) return;
10326
+ if (!guardTestContact(res, contact)) return;
10327
+ contacts.push(contact);
10328
+ }
10329
+ const ctx = mailer.getRunnerContext();
10330
+ const dispatchNow = parsed.data.dispatch !== "queue";
10331
+ const sends = [];
10332
+ for (const contact of contacts) {
10333
+ const { sendId } = await mailer.sendOneOff({
10334
+ templateSlug: tpl.slug,
10335
+ externalId: contact.externalId,
10336
+ dedupeKey: `broadcast-test:${b._id}:${contact.externalId}:${crypto2__default.default.randomUUID()}`,
10337
+ vars: parsed.data.vars
10338
+ });
10339
+ const _id = new mongodb.ObjectId(sendId);
10340
+ await c.sends.updateOne({ _id }, { $set: { manualSendBy: `broadcast-test:${b.slug}` } });
10341
+ if (dispatchNow) await dispatchSend(_id, ctx);
10342
+ const row = await c.sends.findOne({ _id });
10343
+ if (row) sends.push(sendSummary(row));
10344
+ }
10345
+ await mailer.audit({
10346
+ actor: actorOf(req),
10347
+ action: "agent.broadcast.test-send",
10348
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
10349
+ diffSummary: `template=${tpl.slug} to=${contacts.map((x) => x.email).join(", ")} dispatch=${dispatchNow ? "now" : "queue"}`
10350
+ });
10351
+ res.status(201).json({ broadcast: { slug: b.slug, status: b.status }, templateSlug: tpl.slug, dispatched: dispatchNow, sends });
10352
+ })
10353
+ );
10354
+ router.post(
10355
+ "/broadcasts/:slug/pause",
10356
+ wrap2(async (req, res) => {
10357
+ const parsed = agentPauseBroadcastSchema.safeParse(req.body ?? {});
10358
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10359
+ const out = await pauseBroadcastByOperator(mailer, String(req.params.slug), parsed.data, actorOf(req));
10360
+ res.json({ broadcast: broadcastSummary(out.broadcast), heldSends: out.heldSends });
10361
+ })
10362
+ );
10363
+ router.post(
10364
+ "/broadcasts/:slug/resume",
10365
+ wrap2(async (req, res) => {
10366
+ const parsed = agentResumeBroadcastSchema.safeParse(req.body ?? {});
10367
+ if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
10368
+ const b = await resumeBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
10369
+ requireSubscribed: true,
10370
+ requireExactCount: true
10371
+ });
10372
+ res.json({ broadcast: broadcastSummary(b) });
10373
+ })
10374
+ );
10375
+ router.post(
10376
+ "/broadcasts/:slug/cancel",
10377
+ wrap2(async (req, res) => {
10378
+ const out = await cancelBroadcast(mailer, String(req.params.slug), actorOf(req));
10379
+ res.json({ broadcast: broadcastSummary(out.broadcast), cancelledSends: out.cancelledSends });
10380
+ })
10381
+ );
9237
10382
  router.post(
9238
10383
  "/tick",
9239
10384
  wrap2(async (_req, res) => {
@@ -9323,6 +10468,9 @@ function createAgentRouter(mailer, opts) {
9323
10468
  if (err instanceof FlowOperationError) {
9324
10469
  return res.status(err.status).json({ error: err.code, message: err.message });
9325
10470
  }
10471
+ if (err instanceof BroadcastOperationError) {
10472
+ return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
10473
+ }
9326
10474
  if (err?.name === "ZodError") {
9327
10475
  return res.status(400).json({ error: "validation_failed", issues: err.issues });
9328
10476
  }
@@ -9691,6 +10839,9 @@ function runSummary(r) {
9691
10839
  history: r.history
9692
10840
  };
9693
10841
  }
10842
+ function zodMessage(error) {
10843
+ return error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ");
10844
+ }
9694
10845
  function objectOrUndefined(v) {
9695
10846
  return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
9696
10847
  }
@@ -9729,6 +10880,16 @@ var ENDPOINTS = [
9729
10880
  { method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
9730
10881
  { 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 },
9731
10882
  { 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 },
10883
+ { method: "GET", path: "/broadcasts", summary: "Every broadcast (newest first, up to 200) with its stats." },
10884
+ { 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." },
10885
+ { method: "POST", path: "/broadcasts", summary: "Create a draft broadcast: {slug, name, templateSlug, segmentDefinition?, respectRecipientTimezone?}." },
10886
+ { method: "PATCH", path: "/broadcasts/:slug", summary: "Edit a draft broadcast (name, templateSlug, segmentDefinition, respectRecipientTimezone). 409 once it has left draft." },
10887
+ { 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." },
10888
+ { 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." },
10889
+ { 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 },
10890
+ { method: "POST", path: "/broadcasts/:slug/pause", summary: "Pause a sending broadcast by hand ({reason?}); its queued sends are held." },
10891
+ { 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." },
10892
+ { method: "POST", path: "/broadcasts/:slug/cancel", summary: "Cancel a broadcast." },
9732
10893
  { method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
9733
10894
  { method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
9734
10895
  { method: "GET", path: "/status", summary: "One document with setup checks, health, every flow (enabled, version, watermark, gate, active runs), every template, and 24h counts." }
@@ -9969,12 +11130,15 @@ function createPublicRouter(mailer, opts = {}) {
9969
11130
  return;
9970
11131
  }
9971
11132
  const now = /* @__PURE__ */ new Date();
11133
+ await mailer.collections.sends.updateOne(
11134
+ { _id: sendId, status: "sent" },
11135
+ { $set: { status: "delivered" } }
11136
+ );
9972
11137
  await mailer.collections.sends.updateOne(
9973
11138
  { _id: sendId },
9974
11139
  {
9975
11140
  $set: {
9976
- openedAt: send.openedAt ?? now,
9977
- status: "delivered"
11141
+ openedAt: send.openedAt ?? now
9978
11142
  },
9979
11143
  $inc: { openCount: 1 },
9980
11144
  // Per-open user agent, so `hasOpenedExcludingBots` has a signal to
@@ -10113,6 +11277,11 @@ function createPublicRouter(mailer, opts = {}) {
10113
11277
  }
10114
11278
  }
10115
11279
  res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
11280
+ if (!dbError && decoded.sendId) {
11281
+ void attributeUnsubscribeToSend(mailer.getRunnerContext(), decoded.sendId, decoded.email).catch((err) => {
11282
+ logger.warn?.({ err, sendId: decoded.sendId }, "mailery: unsubscribe attribution failed");
11283
+ });
11284
+ }
10116
11285
  }));
10117
11286
  router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
10118
11287
  const token = req.params.token;
@@ -10444,7 +11613,7 @@ var DEDUPE_POLICIES = [
10444
11613
  ];
10445
11614
 
10446
11615
  // src/server/index.ts
10447
- var VERSION2 = "0.17.1" ;
11616
+ var VERSION2 = "0.18.0" ;
10448
11617
 
10449
11618
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
10450
11619
  exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;