mailery 0.17.1 → 0.19.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/admin/spa/index-B4zQqLvr.js.map +1 -1
- package/dist/index.cjs +1548 -353
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -5
- package/dist/index.d.ts +29 -5
- package/dist/index.js +1548 -353
- package/dist/index.js.map +1 -1
- package/dist/{null-DhkTG7mq.d.cts → null-DxTHQSrJ.d.cts} +164 -8
- package/dist/{null-DhkTG7mq.d.ts → null-DxTHQSrJ.d.ts} +164 -8
- package/dist/testing.cjs +808 -210
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +5 -2
- package/dist/testing.d.ts +5 -2
- package/dist/testing.js +808 -210
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
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"];
|
|
@@ -1592,11 +1717,11 @@ var ZERO_COUNTERS = {
|
|
|
1592
1717
|
complained: 0,
|
|
1593
1718
|
failedToSend: 0
|
|
1594
1719
|
};
|
|
1595
|
-
var
|
|
1596
|
-
bounceRate:
|
|
1597
|
-
hardBounceRate:
|
|
1598
|
-
complaintRate:
|
|
1599
|
-
failureRate:
|
|
1720
|
+
var EMPTY_RATES = {
|
|
1721
|
+
bounceRate: null,
|
|
1722
|
+
hardBounceRate: null,
|
|
1723
|
+
complaintRate: null,
|
|
1724
|
+
failureRate: null
|
|
1600
1725
|
};
|
|
1601
1726
|
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1602
1727
|
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
@@ -1626,7 +1751,7 @@ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowM
|
|
|
1626
1751
|
trippedAt: null,
|
|
1627
1752
|
trippedReason: null,
|
|
1628
1753
|
manuallyResumedAt: null,
|
|
1629
|
-
rates: { ...
|
|
1754
|
+
rates: { ...EMPTY_RATES }
|
|
1630
1755
|
},
|
|
1631
1756
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1632
1757
|
},
|
|
@@ -1653,7 +1778,7 @@ async function evaluateHealth(ctx) {
|
|
|
1653
1778
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1654
1779
|
windowDurationMs: windowMs,
|
|
1655
1780
|
counters: { ...ZERO_COUNTERS },
|
|
1656
|
-
rates: { ...
|
|
1781
|
+
rates: { ...EMPTY_RATES },
|
|
1657
1782
|
status: "healthy",
|
|
1658
1783
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1659
1784
|
}
|
|
@@ -1661,13 +1786,21 @@ async function evaluateHealth(ctx) {
|
|
|
1661
1786
|
);
|
|
1662
1787
|
continue;
|
|
1663
1788
|
}
|
|
1664
|
-
const c = doc.counters;
|
|
1665
|
-
const
|
|
1789
|
+
const c = { ...ZERO_COUNTERS, ...doc.counters };
|
|
1790
|
+
const perSent = (n) => c.sent > 0 ? n / c.sent : null;
|
|
1791
|
+
const attempts = c.sent + c.failedToSend;
|
|
1666
1792
|
const rates = {
|
|
1667
|
-
bounceRate: c.bounced
|
|
1668
|
-
hardBounceRate: c.hardBounced
|
|
1669
|
-
complaintRate: c.complained
|
|
1670
|
-
|
|
1793
|
+
bounceRate: perSent(c.bounced),
|
|
1794
|
+
hardBounceRate: perSent(c.hardBounced),
|
|
1795
|
+
complaintRate: perSent(c.complained),
|
|
1796
|
+
// Failed attempts over all attempts, so it stays within 0–100%.
|
|
1797
|
+
failureRate: attempts > 0 ? c.failedToSend / attempts : null
|
|
1798
|
+
};
|
|
1799
|
+
const pct2 = {
|
|
1800
|
+
bounce: (rates.bounceRate ?? 0) * 100,
|
|
1801
|
+
hardBounce: (rates.hardBounceRate ?? 0) * 100,
|
|
1802
|
+
complaint: (rates.complaintRate ?? 0) * 100,
|
|
1803
|
+
failure: (rates.failureRate ?? 0) * 100
|
|
1671
1804
|
};
|
|
1672
1805
|
await ctx.collections.health.updateOne(
|
|
1673
1806
|
{ _id: doc._id },
|
|
@@ -1677,12 +1810,12 @@ async function evaluateHealth(ctx) {
|
|
|
1677
1810
|
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1678
1811
|
if (doc.status === "tripped") continue;
|
|
1679
1812
|
let trippedReason = null;
|
|
1680
|
-
if (
|
|
1681
|
-
trippedReason = `hard bounce rate ${
|
|
1682
|
-
} else if (
|
|
1683
|
-
trippedReason = `complaint rate ${
|
|
1684
|
-
} else if (
|
|
1685
|
-
trippedReason = `combined bounce rate ${
|
|
1813
|
+
if (pct2.hardBounce >= cb.hardBounceRatePctTrip) {
|
|
1814
|
+
trippedReason = `hard bounce rate ${pct2.hardBounce.toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1815
|
+
} else if (pct2.complaint >= cb.complaintRatePctTrip) {
|
|
1816
|
+
trippedReason = `complaint rate ${pct2.complaint.toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1817
|
+
} else if (pct2.bounce >= cb.combinedBounceRatePctTrip) {
|
|
1818
|
+
trippedReason = `combined bounce rate ${pct2.bounce.toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1686
1819
|
}
|
|
1687
1820
|
if (trippedReason) {
|
|
1688
1821
|
const result = await ctx.collections.health.updateOne(
|
|
@@ -1717,7 +1850,7 @@ async function evaluateHealth(ctx) {
|
|
|
1717
1850
|
}
|
|
1718
1851
|
continue;
|
|
1719
1852
|
}
|
|
1720
|
-
if (
|
|
1853
|
+
if (pct2.failure >= cb.failedToSendRatePctDegrade) {
|
|
1721
1854
|
if (doc.status !== "degraded") {
|
|
1722
1855
|
await ctx.collections.health.updateOne(
|
|
1723
1856
|
{ _id: doc._id },
|
|
@@ -1746,6 +1879,211 @@ function effectiveOverallStatus(docs) {
|
|
|
1746
1879
|
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1747
1880
|
return "healthy";
|
|
1748
1881
|
}
|
|
1882
|
+
function emptyBroadcastStats() {
|
|
1883
|
+
return withRates({
|
|
1884
|
+
total: 0,
|
|
1885
|
+
accepted: 0,
|
|
1886
|
+
delivered: 0,
|
|
1887
|
+
bounced: 0,
|
|
1888
|
+
hardBounced: 0,
|
|
1889
|
+
softBounced: 0,
|
|
1890
|
+
complained: 0,
|
|
1891
|
+
unsubscribed: 0,
|
|
1892
|
+
opened: 0,
|
|
1893
|
+
clicked: 0,
|
|
1894
|
+
outcomes: 0
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
|
|
1898
|
+
function withRates(c) {
|
|
1899
|
+
return {
|
|
1900
|
+
...c,
|
|
1901
|
+
rates: {
|
|
1902
|
+
deliveryRatePct: pct(c.delivered, c.accepted),
|
|
1903
|
+
bounceRatePct: pct(c.bounced, c.outcomes),
|
|
1904
|
+
hardBounceRatePct: pct(c.hardBounced, c.outcomes),
|
|
1905
|
+
complaintRatePct: pct(c.complained, c.outcomes),
|
|
1906
|
+
unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
|
|
1907
|
+
openRatePct: pct(c.opened, c.delivered),
|
|
1908
|
+
clickRatePct: pct(c.clicked, c.delivered)
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
var set = (field) => ({ $ifNull: [field, false] });
|
|
1913
|
+
var DELIVERED = {
|
|
1914
|
+
$or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
|
|
1915
|
+
};
|
|
1916
|
+
var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
|
|
1917
|
+
var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
|
|
1918
|
+
async function aggregateBroadcastStats(collections, broadcastId) {
|
|
1919
|
+
const rows = await collections.sends.aggregate([
|
|
1920
|
+
{ $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
|
|
1921
|
+
{
|
|
1922
|
+
$group: {
|
|
1923
|
+
_id: "$broadcastId",
|
|
1924
|
+
total: { $sum: 1 },
|
|
1925
|
+
accepted: count(set("$sentAt")),
|
|
1926
|
+
delivered: count(DELIVERED),
|
|
1927
|
+
bounced: count(BOUNCED),
|
|
1928
|
+
hardBounced: count({ $eq: ["$bounceType", "hard"] }),
|
|
1929
|
+
softBounced: count({ $eq: ["$bounceType", "soft"] }),
|
|
1930
|
+
complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
|
|
1931
|
+
unsubscribed: count(set("$unsubscribedAt")),
|
|
1932
|
+
opened: count(set("$openedAt")),
|
|
1933
|
+
clicked: count(set("$firstClickAt")),
|
|
1934
|
+
outcomes: count({ $or: [DELIVERED, BOUNCED] })
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
]).toArray();
|
|
1938
|
+
const out = /* @__PURE__ */ new Map();
|
|
1939
|
+
for (const { _id, ...c } of rows) {
|
|
1940
|
+
if (_id) out.set(String(_id), withRates(c));
|
|
1941
|
+
}
|
|
1942
|
+
return out;
|
|
1943
|
+
}
|
|
1944
|
+
async function broadcastStatsFor(collections, broadcastId) {
|
|
1945
|
+
return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
|
|
1946
|
+
}
|
|
1947
|
+
function effectiveStopRules(config, b) {
|
|
1948
|
+
return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
|
|
1949
|
+
}
|
|
1950
|
+
var RULE_COUNTS = [
|
|
1951
|
+
["complaintRatePct", "complained"],
|
|
1952
|
+
["hardBounceRatePct", "hardBounced"],
|
|
1953
|
+
["unsubscribeRatePct", "unsubscribed"]
|
|
1954
|
+
];
|
|
1955
|
+
function evaluateStopRules(stats, rules) {
|
|
1956
|
+
const sample = stats.outcomes;
|
|
1957
|
+
const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
|
|
1958
|
+
const breaches = [];
|
|
1959
|
+
if (evaluated) {
|
|
1960
|
+
for (const [rule, key] of RULE_COUNTS) {
|
|
1961
|
+
const n = stats[key];
|
|
1962
|
+
const exactPct = n / sample * 100;
|
|
1963
|
+
if (exactPct > rules[rule]) {
|
|
1964
|
+
breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
return { rules, sample, evaluated, breaches };
|
|
1969
|
+
}
|
|
1970
|
+
var RULE_LABEL = {
|
|
1971
|
+
complaintRatePct: "complaint rate",
|
|
1972
|
+
hardBounceRatePct: "hard bounce rate",
|
|
1973
|
+
unsubscribeRatePct: "unsubscribe rate"
|
|
1974
|
+
};
|
|
1975
|
+
var PAUSE_PRECEDENCE = {
|
|
1976
|
+
manual: 1,
|
|
1977
|
+
cap_reached: 1,
|
|
1978
|
+
stop_rule: 2,
|
|
1979
|
+
circuit_breaker: 2
|
|
1980
|
+
};
|
|
1981
|
+
async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
|
|
1982
|
+
const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
|
|
1983
|
+
(k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
|
|
1984
|
+
);
|
|
1985
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
1986
|
+
{
|
|
1987
|
+
_id: broadcastId,
|
|
1988
|
+
$or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
|
|
1989
|
+
},
|
|
1990
|
+
{ $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
|
|
1991
|
+
{ returnDocument: "after" }
|
|
1992
|
+
);
|
|
1993
|
+
if (!b) return { paused: false, heldSends: 0 };
|
|
1994
|
+
const held = await holdQueuedSends(ctx, broadcastId, reason.code);
|
|
1995
|
+
if (ctx.audit) {
|
|
1996
|
+
await ctx.audit({
|
|
1997
|
+
actor,
|
|
1998
|
+
action: "broadcast.pause",
|
|
1999
|
+
resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
|
|
2000
|
+
diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
|
|
2001
|
+
}).catch(() => {
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
if (ctx.config.onBroadcastPaused) {
|
|
2005
|
+
try {
|
|
2006
|
+
await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
|
|
2007
|
+
} catch {
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
return { paused: true, heldSends: held };
|
|
2011
|
+
}
|
|
2012
|
+
async function holdQueuedSends(ctx, broadcastId, code) {
|
|
2013
|
+
const res = await ctx.collections.sends.updateMany(
|
|
2014
|
+
{ broadcastId, status: "queued" },
|
|
2015
|
+
{ $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
2016
|
+
);
|
|
2017
|
+
return res.modifiedCount;
|
|
2018
|
+
}
|
|
2019
|
+
async function releaseHeldSends(ctx, broadcastId) {
|
|
2020
|
+
const held = await ctx.collections.sends.find({ broadcastId, status: "held" }, { projection: { _id: 1, notBefore: 1 } }).toArray();
|
|
2021
|
+
if (held.length === 0) return 0;
|
|
2022
|
+
await ctx.collections.sends.updateMany(
|
|
2023
|
+
{ _id: { $in: held.map((s) => s._id) }, status: "held" },
|
|
2024
|
+
{ $set: { status: "queued", errorMessage: null, updatedAt: /* @__PURE__ */ new Date() } }
|
|
2025
|
+
);
|
|
2026
|
+
const now = Date.now();
|
|
2027
|
+
await Promise.all(
|
|
2028
|
+
held.map((s) => {
|
|
2029
|
+
const delay = s.notBefore ? Math.max(0, new Date(s.notBefore).getTime() - now) : 0;
|
|
2030
|
+
return ctx.queues.send.add(
|
|
2031
|
+
"send",
|
|
2032
|
+
{ sendId: String(s._id) },
|
|
2033
|
+
{
|
|
2034
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
2035
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
2036
|
+
...delay > 0 ? { delay } : {}
|
|
2037
|
+
}
|
|
2038
|
+
);
|
|
2039
|
+
})
|
|
2040
|
+
);
|
|
2041
|
+
return held.length;
|
|
2042
|
+
}
|
|
2043
|
+
async function evaluateBroadcastStopRules(ctx, broadcastId) {
|
|
2044
|
+
const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
|
|
2045
|
+
if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
|
|
2046
|
+
if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
|
|
2047
|
+
const stats = await broadcastStatsFor(ctx.collections, broadcastId);
|
|
2048
|
+
const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
|
|
2049
|
+
if (evaluation.breaches.length === 0) return evaluation;
|
|
2050
|
+
const now = /* @__PURE__ */ new Date();
|
|
2051
|
+
if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
|
|
2052
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2053
|
+
{ _id: broadcastId, stopRuleBreach: { $in: [null] } },
|
|
2054
|
+
{ $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
|
|
2055
|
+
);
|
|
2056
|
+
return evaluation;
|
|
2057
|
+
}
|
|
2058
|
+
const first = evaluation.breaches[0];
|
|
2059
|
+
await pauseBroadcast(ctx, broadcastId, {
|
|
2060
|
+
code: "stop_rule",
|
|
2061
|
+
message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
|
|
2062
|
+
at: now,
|
|
2063
|
+
details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
|
|
2064
|
+
});
|
|
2065
|
+
return evaluation;
|
|
2066
|
+
}
|
|
2067
|
+
var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
2068
|
+
async function evaluateActiveBroadcastStopRules(ctx) {
|
|
2069
|
+
const live = await ctx.collections.broadcasts.find(
|
|
2070
|
+
{ status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
|
|
2071
|
+
{ projection: { _id: 1 } }
|
|
2072
|
+
).limit(200).toArray();
|
|
2073
|
+
for (const { _id } of live) {
|
|
2074
|
+
await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
|
|
2075
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
async function attributeUnsubscribeToSend(ctx, sendId, email) {
|
|
2080
|
+
if (!mongodb.ObjectId.isValid(sendId)) return;
|
|
2081
|
+
const _id = new mongodb.ObjectId(sendId);
|
|
2082
|
+
const send = await ctx.collections.sends.findOne({ _id }, { projection: { emailAtSend: 1, broadcastId: 1 } });
|
|
2083
|
+
if (!send || send.emailAtSend.toLowerCase() !== email.toLowerCase()) return;
|
|
2084
|
+
await ctx.collections.sends.updateOne({ _id, unsubscribedAt: null }, { $set: { unsubscribedAt: /* @__PURE__ */ new Date() } });
|
|
2085
|
+
if (send.broadcastId) await evaluateBroadcastStopRules(ctx, send.broadcastId);
|
|
2086
|
+
}
|
|
1749
2087
|
|
|
1750
2088
|
// src/server/runner/webhook.ts
|
|
1751
2089
|
function dimsFromSend(send) {
|
|
@@ -1900,6 +2238,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
1900
2238
|
);
|
|
1901
2239
|
break;
|
|
1902
2240
|
}
|
|
2241
|
+
if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
|
|
2242
|
+
await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
|
|
2243
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
1903
2246
|
}
|
|
1904
2247
|
async function suppressOnce(ctx, email, reason, scope) {
|
|
1905
2248
|
const normalized = email.toLowerCase();
|
|
@@ -2311,11 +2654,30 @@ async function isSuppressed(collections, email, kind) {
|
|
|
2311
2654
|
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
2312
2655
|
const hashed = await collections.suppressions.findOne({
|
|
2313
2656
|
emailHash: sha256Hex(normalized),
|
|
2314
|
-
scope: { $in: allowed }
|
|
2657
|
+
scope: { $in: allowed },
|
|
2658
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
2315
2659
|
});
|
|
2316
2660
|
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
2317
2661
|
return { suppressed: false };
|
|
2318
2662
|
}
|
|
2663
|
+
async function suppressedEmails(collections, emails, kind) {
|
|
2664
|
+
const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
|
|
2665
|
+
if (normalized.length === 0) return /* @__PURE__ */ new Set();
|
|
2666
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
2667
|
+
const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
|
|
2668
|
+
const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
|
|
2669
|
+
const [plain, hashed] = await Promise.all([
|
|
2670
|
+
collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
|
|
2671
|
+
collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
|
|
2672
|
+
]);
|
|
2673
|
+
const out = /* @__PURE__ */ new Set();
|
|
2674
|
+
for (const e of plain) if (typeof e === "string") out.add(e);
|
|
2675
|
+
for (const h of hashed) {
|
|
2676
|
+
const e = byHash.get(String(h));
|
|
2677
|
+
if (e) out.add(e);
|
|
2678
|
+
}
|
|
2679
|
+
return out;
|
|
2680
|
+
}
|
|
2319
2681
|
|
|
2320
2682
|
// src/server/runner/send.ts
|
|
2321
2683
|
async function handleSend(run, step, contact, flow, ctx) {
|
|
@@ -2392,6 +2754,27 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2392
2754
|
await markFailed(send._id, "template_missing", ctx);
|
|
2393
2755
|
return;
|
|
2394
2756
|
}
|
|
2757
|
+
if (send.broadcastId) {
|
|
2758
|
+
const broadcast = await ctx.collections.broadcasts.findOne(
|
|
2759
|
+
{ _id: send.broadcastId },
|
|
2760
|
+
{ projection: { status: 1, pauseReason: 1 } }
|
|
2761
|
+
);
|
|
2762
|
+
const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
|
|
2763
|
+
if (holding || broadcast?.status === "cancelled") {
|
|
2764
|
+
const held = holding;
|
|
2765
|
+
await ctx.collections.sends.updateOne(
|
|
2766
|
+
{ _id: send._id },
|
|
2767
|
+
{
|
|
2768
|
+
$set: {
|
|
2769
|
+
status: held ? "held" : "cancelled",
|
|
2770
|
+
errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
|
|
2771
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2395
2778
|
const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
|
|
2396
2779
|
if (supp.suppressed) {
|
|
2397
2780
|
await ctx.collections.sends.updateOne(
|
|
@@ -2402,6 +2785,19 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2402
2785
|
}
|
|
2403
2786
|
if (send.kind === "marketing") {
|
|
2404
2787
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
2788
|
+
if (bucket?.status === "tripped" && send.broadcastId) {
|
|
2789
|
+
await ctx.collections.sends.updateOne(
|
|
2790
|
+
{ _id: send._id },
|
|
2791
|
+
{ $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2792
|
+
);
|
|
2793
|
+
await pauseBroadcast(ctx, send.broadcastId, {
|
|
2794
|
+
code: "circuit_breaker",
|
|
2795
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
2796
|
+
at: /* @__PURE__ */ new Date(),
|
|
2797
|
+
details: { bucket: bucket._id }
|
|
2798
|
+
});
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2405
2801
|
if (bucket?.status === "tripped") {
|
|
2406
2802
|
await ctx.collections.sends.updateOne(
|
|
2407
2803
|
{ _id: send._id },
|
|
@@ -2416,6 +2812,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2416
2812
|
await markFailed(send._id, "contact_missing", ctx);
|
|
2417
2813
|
return;
|
|
2418
2814
|
}
|
|
2815
|
+
if (!send.emailAtSend?.trim() || !contact.email?.trim()) {
|
|
2816
|
+
await markFailed(send._id, "no_recipient: the contact has no email address", ctx);
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2419
2819
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
2420
2820
|
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
2421
2821
|
await ctx.collections.sends.updateOne(
|
|
@@ -2434,7 +2834,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2434
2834
|
eventName: run?.triggerEvent?.name,
|
|
2435
2835
|
eventProperties: run?.triggerEvent?.properties
|
|
2436
2836
|
});
|
|
2437
|
-
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
2837
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
|
|
2438
2838
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
2439
2839
|
} catch (err) {
|
|
2440
2840
|
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
@@ -2525,11 +2925,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
2525
2925
|
}
|
|
2526
2926
|
return ctx.config.defaultProvider;
|
|
2527
2927
|
}
|
|
2528
|
-
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
2928
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
|
|
2529
2929
|
const scope = "marketing";
|
|
2530
2930
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
2531
2931
|
const token = signUnsubscribeToken(
|
|
2532
|
-
{ email: contact.email, scope, expiresAt },
|
|
2932
|
+
{ email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
|
|
2533
2933
|
ctx.config.unsubscribeSecret
|
|
2534
2934
|
);
|
|
2535
2935
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
@@ -2612,6 +3012,10 @@ async function processOneRunStep(runId, ctx) {
|
|
|
2612
3012
|
await exitFlowRun(run, "contact_missing", ctx);
|
|
2613
3013
|
return;
|
|
2614
3014
|
}
|
|
3015
|
+
if (!contact.email?.trim()) {
|
|
3016
|
+
await exitFlowRun(run, "contact_no_email", ctx);
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
2615
3019
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
|
|
2616
3020
|
if (sub && sub.status !== "subscribed") {
|
|
2617
3021
|
await exitFlowRun(run, sub.status, ctx);
|
|
@@ -2905,6 +3309,184 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
2905
3309
|
}
|
|
2906
3310
|
}
|
|
2907
3311
|
}
|
|
3312
|
+
|
|
3313
|
+
// src/server/runner/segment.ts
|
|
3314
|
+
function planSegment(seg) {
|
|
3315
|
+
const hostFilter = {};
|
|
3316
|
+
const postFilters = [];
|
|
3317
|
+
const hostFields = /* @__PURE__ */ new Set();
|
|
3318
|
+
const takeField = (field) => {
|
|
3319
|
+
if (hostFields.has(field)) return false;
|
|
3320
|
+
hostFields.add(field);
|
|
3321
|
+
return true;
|
|
3322
|
+
};
|
|
3323
|
+
for (const f of seg.filters ?? []) {
|
|
3324
|
+
switch (f.kind) {
|
|
3325
|
+
case "hasTag":
|
|
3326
|
+
if (hostFilter.hasTag === void 0) {
|
|
3327
|
+
hostFilter.hasTag = f.tag;
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
break;
|
|
3331
|
+
case "fieldEquals":
|
|
3332
|
+
if (!hostFilter.fieldEquals && takeField(f.field)) {
|
|
3333
|
+
hostFilter.fieldEquals = { field: f.field, value: f.value };
|
|
3334
|
+
continue;
|
|
3335
|
+
}
|
|
3336
|
+
break;
|
|
3337
|
+
case "fieldIn":
|
|
3338
|
+
if (!hostFilter.fieldIn && takeField(f.field)) {
|
|
3339
|
+
hostFilter.fieldIn = { field: f.field, values: f.values };
|
|
3340
|
+
continue;
|
|
3341
|
+
}
|
|
3342
|
+
break;
|
|
3343
|
+
case "fieldExists":
|
|
3344
|
+
if (!hostFilter.fieldExists && takeField(f.field)) {
|
|
3345
|
+
hostFilter.fieldExists = f.field;
|
|
3346
|
+
continue;
|
|
3347
|
+
}
|
|
3348
|
+
break;
|
|
3349
|
+
}
|
|
3350
|
+
postFilters.push(f);
|
|
3351
|
+
}
|
|
3352
|
+
return { hostFilter, postFilters };
|
|
3353
|
+
}
|
|
3354
|
+
async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
|
|
3355
|
+
if (filters.length === 0 || contacts.length === 0) return contacts;
|
|
3356
|
+
const externalIds = contacts.map((c) => c.externalId);
|
|
3357
|
+
const leaves = /* @__PURE__ */ new Map();
|
|
3358
|
+
collectLookups(filters, leaves);
|
|
3359
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3360
|
+
await Promise.all(
|
|
3361
|
+
[...leaves].map(async ([key, f]) => {
|
|
3362
|
+
cache.set(key, await lookup(f, externalIds, ctx, now));
|
|
3363
|
+
})
|
|
3364
|
+
);
|
|
3365
|
+
return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
|
|
3366
|
+
}
|
|
3367
|
+
function lookupKey(f) {
|
|
3368
|
+
switch (f.kind) {
|
|
3369
|
+
case "subscriptionStatus":
|
|
3370
|
+
return `sub:${f.equals}`;
|
|
3371
|
+
case "firedEvent":
|
|
3372
|
+
case "notFiredEvent":
|
|
3373
|
+
return `evt:${f.withinDays ?? ""}:${f.eventName}`;
|
|
3374
|
+
case "opened":
|
|
3375
|
+
case "notOpened":
|
|
3376
|
+
return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
|
|
3377
|
+
case "subscribedAfter":
|
|
3378
|
+
return `subAfter:${toDate(f.date).getTime()}`;
|
|
3379
|
+
case "subscribedBefore":
|
|
3380
|
+
return `subBefore:${toDate(f.date).getTime()}`;
|
|
3381
|
+
default:
|
|
3382
|
+
return null;
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
function collectLookups(filters, out) {
|
|
3386
|
+
for (const f of filters) {
|
|
3387
|
+
if (f.kind === "any") collectLookups(f.filters, out);
|
|
3388
|
+
else if (f.kind === "not") collectLookups([f.filter], out);
|
|
3389
|
+
else {
|
|
3390
|
+
const key = lookupKey(f);
|
|
3391
|
+
if (key && !out.has(key)) out.set(key, f);
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
async function lookup(f, externalIds, ctx, now) {
|
|
3396
|
+
const c = ctx.collections;
|
|
3397
|
+
const inPage = { $in: externalIds };
|
|
3398
|
+
const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
|
|
3399
|
+
let ids;
|
|
3400
|
+
switch (f.kind) {
|
|
3401
|
+
case "subscriptionStatus":
|
|
3402
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
|
|
3403
|
+
break;
|
|
3404
|
+
case "firedEvent":
|
|
3405
|
+
case "notFiredEvent": {
|
|
3406
|
+
const cutoff = since(f.withinDays);
|
|
3407
|
+
ids = await c.events.distinct("externalId", {
|
|
3408
|
+
externalId: inPage,
|
|
3409
|
+
name: f.eventName,
|
|
3410
|
+
...cutoff ? { occurredAt: { $gt: cutoff } } : {}
|
|
3411
|
+
});
|
|
3412
|
+
break;
|
|
3413
|
+
}
|
|
3414
|
+
case "opened":
|
|
3415
|
+
case "notOpened": {
|
|
3416
|
+
const cutoff = since(f.withinDays);
|
|
3417
|
+
ids = await c.sends.distinct("externalId", {
|
|
3418
|
+
externalId: inPage,
|
|
3419
|
+
openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
|
|
3420
|
+
...f.templateSlug ? { templateSlug: f.templateSlug } : {}
|
|
3421
|
+
});
|
|
3422
|
+
break;
|
|
3423
|
+
}
|
|
3424
|
+
case "subscribedAfter":
|
|
3425
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
|
|
3426
|
+
break;
|
|
3427
|
+
case "subscribedBefore":
|
|
3428
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
|
|
3429
|
+
break;
|
|
3430
|
+
default:
|
|
3431
|
+
ids = [];
|
|
3432
|
+
}
|
|
3433
|
+
return new Set(ids.map(String));
|
|
3434
|
+
}
|
|
3435
|
+
function matches(c, f, cache) {
|
|
3436
|
+
switch (f.kind) {
|
|
3437
|
+
case "subscriptionStatus":
|
|
3438
|
+
case "firedEvent":
|
|
3439
|
+
case "opened":
|
|
3440
|
+
case "subscribedAfter":
|
|
3441
|
+
case "subscribedBefore":
|
|
3442
|
+
return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
|
|
3443
|
+
case "notFiredEvent":
|
|
3444
|
+
case "notOpened":
|
|
3445
|
+
return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
|
|
3446
|
+
case "hasTag":
|
|
3447
|
+
return c.tags.includes(f.tag);
|
|
3448
|
+
case "notHasTag":
|
|
3449
|
+
return !c.tags.includes(f.tag);
|
|
3450
|
+
case "fieldEquals":
|
|
3451
|
+
return valuesEqual(fieldValue(c, f.field), f.value);
|
|
3452
|
+
case "fieldIn": {
|
|
3453
|
+
const v = fieldValue(c, f.field);
|
|
3454
|
+
return f.values.some((x) => valuesEqual(v, x));
|
|
3455
|
+
}
|
|
3456
|
+
case "fieldExists":
|
|
3457
|
+
return fieldValue(c, f.field) !== void 0;
|
|
3458
|
+
case "any":
|
|
3459
|
+
return f.filters.some((sub) => matches(c, sub, cache));
|
|
3460
|
+
case "not":
|
|
3461
|
+
return !matches(c, f.filter, cache);
|
|
3462
|
+
default:
|
|
3463
|
+
throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
function fieldValue(c, path3) {
|
|
3467
|
+
let cur = c.fields;
|
|
3468
|
+
for (const part of path3.split(".")) {
|
|
3469
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
3470
|
+
cur = cur[part];
|
|
3471
|
+
}
|
|
3472
|
+
return cur;
|
|
3473
|
+
}
|
|
3474
|
+
function valuesEqual(a, b) {
|
|
3475
|
+
if (a instanceof Date || b instanceof Date) {
|
|
3476
|
+
const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
|
|
3477
|
+
const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
|
|
3478
|
+
return !Number.isNaN(ta) && ta === tb;
|
|
3479
|
+
}
|
|
3480
|
+
if (a && typeof a === "object" && typeof a.toHexString === "function") {
|
|
3481
|
+
return String(a) === String(b);
|
|
3482
|
+
}
|
|
3483
|
+
return a === b;
|
|
3484
|
+
}
|
|
3485
|
+
function toDate(d) {
|
|
3486
|
+
return d instanceof Date ? d : new Date(d);
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
// src/server/runner/broadcasts.ts
|
|
2908
3490
|
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
2909
3491
|
async function processScheduledBroadcasts(ctx) {
|
|
2910
3492
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -2912,7 +3494,7 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2912
3494
|
for (const b of due) {
|
|
2913
3495
|
const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
2914
3496
|
{ _id: b._id, status: "scheduled" },
|
|
2915
|
-
{ $set: { status: "sending", startedAt: now, updatedAt: now } },
|
|
3497
|
+
{ $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
|
|
2916
3498
|
{ returnDocument: "after" }
|
|
2917
3499
|
);
|
|
2918
3500
|
if (!claimed) continue;
|
|
@@ -2920,8 +3502,14 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2920
3502
|
}
|
|
2921
3503
|
}
|
|
2922
3504
|
async function startBroadcastDispatch(broadcast, ctx) {
|
|
3505
|
+
const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
3506
|
+
{ _id: broadcast._id },
|
|
3507
|
+
{ $inc: { dispatchGeneration: 1 } },
|
|
3508
|
+
{ returnDocument: "after" }
|
|
3509
|
+
);
|
|
3510
|
+
const current = bumped ?? broadcast;
|
|
2923
3511
|
if (ctx.config.queue.driver === "noop") {
|
|
2924
|
-
await runBroadcastDispatch(
|
|
3512
|
+
await runBroadcastDispatch(current, ctx);
|
|
2925
3513
|
return;
|
|
2926
3514
|
}
|
|
2927
3515
|
await ctx.queues.advance.add(
|
|
@@ -2930,7 +3518,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
|
|
|
2930
3518
|
{
|
|
2931
3519
|
attempts: 3,
|
|
2932
3520
|
backoff: { type: "exponential", delay: 6e4 },
|
|
2933
|
-
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
3521
|
+
jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
|
|
2934
3522
|
}
|
|
2935
3523
|
);
|
|
2936
3524
|
}
|
|
@@ -2943,10 +3531,11 @@ async function resumeStalledBroadcasts(ctx) {
|
|
|
2943
3531
|
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
2944
3532
|
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
2945
3533
|
for (const b of stalled) {
|
|
2946
|
-
await ctx.collections.broadcasts.updateOne(
|
|
2947
|
-
{ _id: b._id },
|
|
2948
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
3534
|
+
const reset = await ctx.collections.broadcasts.updateOne(
|
|
3535
|
+
{ _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
3536
|
+
{ $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
|
|
2949
3537
|
);
|
|
3538
|
+
if (reset.modifiedCount === 0) continue;
|
|
2950
3539
|
await startBroadcastDispatch(b, ctx);
|
|
2951
3540
|
}
|
|
2952
3541
|
}
|
|
@@ -2956,166 +3545,196 @@ async function runBroadcastDispatch(broadcast, ctx) {
|
|
|
2956
3545
|
} catch (err) {
|
|
2957
3546
|
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
2958
3547
|
await ctx.collections.broadcasts.updateOne(
|
|
2959
|
-
{ _id: broadcast._id },
|
|
2960
|
-
{
|
|
3548
|
+
{ _id: broadcast._id, status: "sending" },
|
|
3549
|
+
{
|
|
3550
|
+
$set: {
|
|
3551
|
+
status: "failed",
|
|
3552
|
+
failureReason: `dispatch error: ${String(err?.message ?? err)}`,
|
|
3553
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
3554
|
+
dispatchLeaseId: null
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
2961
3557
|
);
|
|
2962
3558
|
}
|
|
2963
3559
|
}
|
|
3560
|
+
function broadcastDedupeKey(broadcastId, externalId) {
|
|
3561
|
+
return `broadcast:${broadcastId}:${externalId}`;
|
|
3562
|
+
}
|
|
3563
|
+
var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3564
|
+
function isSendableEmail(email) {
|
|
3565
|
+
return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
|
|
3566
|
+
}
|
|
3567
|
+
async function* eligibleRecipientPages(broadcast, kind, ctx) {
|
|
3568
|
+
const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
|
|
3569
|
+
const sort = broadcast.order ?? void 0;
|
|
3570
|
+
if (sort && !ctx.adapter.supportsSort) {
|
|
3571
|
+
throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
|
|
3572
|
+
}
|
|
3573
|
+
let cursor;
|
|
3574
|
+
for (; ; ) {
|
|
3575
|
+
const page = await ctx.adapter.query(hostFilter, {
|
|
3576
|
+
limit: ctx.config.broadcastEnqueueBatchSize,
|
|
3577
|
+
cursor,
|
|
3578
|
+
...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
|
|
3579
|
+
});
|
|
3580
|
+
if (page.contacts.length === 0) break;
|
|
3581
|
+
const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
|
|
3582
|
+
const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
|
|
3583
|
+
yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
|
|
3584
|
+
if (!page.nextCursor) break;
|
|
3585
|
+
cursor = page.nextCursor;
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
async function alreadyDispatched(ctx, broadcastId, contacts) {
|
|
3589
|
+
if (contacts.length === 0) return /* @__PURE__ */ new Set();
|
|
3590
|
+
const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
|
|
3591
|
+
const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
|
|
3592
|
+
return new Set(found.map(String));
|
|
3593
|
+
}
|
|
3594
|
+
async function countBroadcastRecipients(broadcast, kind, ctx) {
|
|
3595
|
+
const t0 = Date.now();
|
|
3596
|
+
const { hostFilter } = planSegment(broadcast.segmentDefinition);
|
|
3597
|
+
const [hostMatched, sendsSoFar] = await Promise.all([
|
|
3598
|
+
ctx.adapter.count(hostFilter),
|
|
3599
|
+
broadcast._id ? ctx.collections.sends.countDocuments({ broadcastId: broadcast._id }) : Promise.resolve(0)
|
|
3600
|
+
]);
|
|
3601
|
+
let eligible = 0;
|
|
3602
|
+
let alreadySent = 0;
|
|
3603
|
+
for await (const page of eligibleRecipientPages(broadcast, kind, ctx)) {
|
|
3604
|
+
eligible += page.length;
|
|
3605
|
+
if (broadcast._id) alreadySent += (await alreadyDispatched(ctx, broadcast._id, page)).size;
|
|
3606
|
+
}
|
|
3607
|
+
const cap = typeof broadcast.recipientCap === "number" ? broadcast.recipientCap : null;
|
|
3608
|
+
const uncapped = eligible - alreadySent;
|
|
3609
|
+
const recipientCount = cap === null ? uncapped : Math.max(0, Math.min(uncapped, cap - sendsSoFar));
|
|
3610
|
+
return {
|
|
3611
|
+
hostMatched,
|
|
3612
|
+
eligible,
|
|
3613
|
+
alreadySent,
|
|
3614
|
+
sendsSoFar,
|
|
3615
|
+
recipientCap: cap,
|
|
3616
|
+
uncappedRecipientCount: uncapped,
|
|
3617
|
+
recipientCount,
|
|
3618
|
+
computedMs: Date.now() - t0
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
2964
3621
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
2965
3622
|
const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
|
|
2966
|
-
|
|
3623
|
+
const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
|
|
3624
|
+
if (failure || !template) {
|
|
2967
3625
|
await ctx.collections.broadcasts.updateOne(
|
|
2968
|
-
{ _id: broadcast._id },
|
|
2969
|
-
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3626
|
+
{ _id: broadcast._id, status: "sending" },
|
|
3627
|
+
{ $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
|
|
2970
3628
|
);
|
|
2971
3629
|
return;
|
|
2972
3630
|
}
|
|
2973
|
-
const
|
|
2974
|
-
const
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
3631
|
+
const leaseId = new mongodb.ObjectId().toHexString();
|
|
3632
|
+
const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
3633
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
3634
|
+
{
|
|
3635
|
+
_id: broadcast._id,
|
|
3636
|
+
status: "sending",
|
|
3637
|
+
$or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
|
|
3638
|
+
},
|
|
3639
|
+
{ $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
|
|
3640
|
+
{ returnDocument: "after" }
|
|
3641
|
+
);
|
|
3642
|
+
if (!b) return;
|
|
3643
|
+
const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
|
|
3644
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
3645
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
3646
|
+
)).matchedCount === 1;
|
|
2978
3647
|
const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
|
|
2979
|
-
const respectTimezone =
|
|
2980
|
-
const scheduledMs =
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
3648
|
+
const respectTimezone = b.respectRecipientTimezone === true;
|
|
3649
|
+
const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
|
|
3650
|
+
const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
|
|
3651
|
+
let capReached = false;
|
|
3652
|
+
try {
|
|
3653
|
+
for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
|
|
3654
|
+
if (!await holdsLease()) return;
|
|
3655
|
+
const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
|
|
3656
|
+
if (bucket?.status === "tripped") {
|
|
3657
|
+
await pauseBroadcast(ctx, b._id, {
|
|
3658
|
+
code: "circuit_breaker",
|
|
3659
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
3660
|
+
at: /* @__PURE__ */ new Date(),
|
|
3661
|
+
details: { bucket: bucket._id }
|
|
3662
|
+
});
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
const seen = await alreadyDispatched(ctx, b._id, eligible);
|
|
3666
|
+
let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
|
|
3667
|
+
if (fresh.length === 0) continue;
|
|
3668
|
+
if (cap !== null) {
|
|
3669
|
+
const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
3670
|
+
if (room <= 0) {
|
|
3671
|
+
capReached = true;
|
|
3672
|
+
break;
|
|
3673
|
+
}
|
|
3674
|
+
if (fresh.length > room) {
|
|
3675
|
+
fresh = fresh.slice(0, room);
|
|
3676
|
+
capReached = true;
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
2990
3679
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
2991
|
-
await
|
|
2992
|
-
{ _id: broadcast._id },
|
|
2993
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2994
|
-
);
|
|
3680
|
+
if (!await holdsLease()) return;
|
|
2995
3681
|
await sleep(2e3);
|
|
2996
3682
|
}
|
|
2997
|
-
const sendDocs = await Promise.all(
|
|
2998
|
-
eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
|
|
2999
|
-
);
|
|
3000
3683
|
const inserted = [];
|
|
3001
|
-
for (const
|
|
3002
|
-
|
|
3684
|
+
for (const contact of fresh) {
|
|
3685
|
+
const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
|
|
3003
3686
|
try {
|
|
3004
3687
|
await ctx.collections.sends.insertOne(doc);
|
|
3005
3688
|
inserted.push({ sendId: doc._id, delayMs });
|
|
3006
3689
|
} catch (err) {
|
|
3007
3690
|
if (err?.code !== 11e3) throw err;
|
|
3008
3691
|
}
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
}
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
}
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
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;
|
|
3692
|
+
}
|
|
3693
|
+
await Promise.all(
|
|
3694
|
+
inserted.map(
|
|
3695
|
+
({ sendId, delayMs }) => ctx.queues.send.add(
|
|
3696
|
+
"send",
|
|
3697
|
+
{ sendId: String(sendId) },
|
|
3698
|
+
{
|
|
3699
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
3700
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
3701
|
+
...delayMs > 0 ? { delay: delayMs } : {}
|
|
3702
|
+
}
|
|
3703
|
+
)
|
|
3704
|
+
)
|
|
3705
|
+
);
|
|
3706
|
+
if (capReached) break;
|
|
3707
|
+
}
|
|
3708
|
+
const now = /* @__PURE__ */ new Date();
|
|
3709
|
+
const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
3710
|
+
const set2 = capReached ? {
|
|
3711
|
+
status: "paused",
|
|
3712
|
+
pausedAt: now,
|
|
3713
|
+
pauseReason: {
|
|
3714
|
+
code: "cap_reached",
|
|
3715
|
+
message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
|
|
3716
|
+
at: now,
|
|
3717
|
+
details: { recipientCap: cap, sendsSoFar: total }
|
|
3718
|
+
}
|
|
3719
|
+
} : { status: "sent", completedAt: now };
|
|
3720
|
+
await ctx.collections.broadcasts.updateOne(
|
|
3721
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
3722
|
+
{ $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
|
|
3723
|
+
);
|
|
3724
|
+
} finally {
|
|
3725
|
+
await ctx.collections.broadcasts.updateOne(
|
|
3726
|
+
{ _id: b._id, dispatchLeaseId: leaseId },
|
|
3727
|
+
{ $set: { dispatchLeaseId: null } }
|
|
3728
|
+
);
|
|
3108
3729
|
}
|
|
3109
3730
|
}
|
|
3110
|
-
|
|
3111
|
-
const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
|
|
3112
|
-
if (supp.suppressed) return { doc: null, delayMs: 0 };
|
|
3731
|
+
function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
|
|
3113
3732
|
const sendId = new mongodb.ObjectId();
|
|
3114
|
-
const dedupeKey =
|
|
3115
|
-
|
|
3733
|
+
const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
|
|
3734
|
+
const now = Date.now();
|
|
3735
|
+
let delayMs = Math.max(0, scheduledMs - now);
|
|
3116
3736
|
if (respectTimezone && contact.timezone) {
|
|
3117
|
-
|
|
3118
|
-
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
3737
|
+
delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
|
|
3119
3738
|
}
|
|
3120
3739
|
const doc = {
|
|
3121
3740
|
_id: sendId,
|
|
@@ -3150,16 +3769,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
3150
3769
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
3151
3770
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
3152
3771
|
sentAt: null,
|
|
3153
|
-
deliveredAt: null
|
|
3772
|
+
deliveredAt: null,
|
|
3773
|
+
notBefore: new Date(Date.now() + delayMs)
|
|
3154
3774
|
};
|
|
3155
3775
|
return { doc, delayMs };
|
|
3156
3776
|
}
|
|
3777
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3778
|
+
var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
|
|
3779
|
+
function recipientSlotMs(scheduledMs, timezone, nowMs) {
|
|
3780
|
+
let anchor = scheduledMs;
|
|
3781
|
+
let target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
3782
|
+
const earliest = nowMs - TZ_SLOT_GRACE_MS;
|
|
3783
|
+
if (target < earliest) {
|
|
3784
|
+
anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
|
|
3785
|
+
target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
3786
|
+
if (target < earliest) target += DAY_MS;
|
|
3787
|
+
}
|
|
3788
|
+
return target;
|
|
3789
|
+
}
|
|
3157
3790
|
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
3158
|
-
const DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
3159
3791
|
try {
|
|
3160
3792
|
const scheduled = new Date(scheduledMs);
|
|
3161
|
-
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC",
|
|
3162
|
-
const local = scheduled.toLocaleString("en-US", { timeZone: timezone,
|
|
3793
|
+
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
|
|
3794
|
+
const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
|
|
3163
3795
|
const parse = (s) => {
|
|
3164
3796
|
const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
|
|
3165
3797
|
if (!m) return 0;
|
|
@@ -3168,7 +3800,7 @@ function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
|
3168
3800
|
const utcMs = parse(utc);
|
|
3169
3801
|
const localMs = parse(local);
|
|
3170
3802
|
const offsetMs = utcMs - localMs;
|
|
3171
|
-
return (offsetMs %
|
|
3803
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
3172
3804
|
} catch {
|
|
3173
3805
|
return 0;
|
|
3174
3806
|
}
|
|
@@ -3279,10 +3911,10 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
3279
3911
|
}
|
|
3280
3912
|
let listedCount = 0;
|
|
3281
3913
|
async function processPair(p) {
|
|
3282
|
-
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
3283
|
-
const
|
|
3284
|
-
if (
|
|
3285
|
-
if (
|
|
3914
|
+
const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
|
|
3915
|
+
const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
3916
|
+
if (lookup2.transient) return;
|
|
3917
|
+
if (lookup2.result === "listed") listedCount++;
|
|
3286
3918
|
await ctx.collections.dnsblChecks.updateOne(
|
|
3287
3919
|
{ target: p.target, list: p.list.host },
|
|
3288
3920
|
{
|
|
@@ -3291,9 +3923,9 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
3291
3923
|
targetKind: p.targetKind,
|
|
3292
3924
|
list: p.list.host,
|
|
3293
3925
|
listLabel: p.list.label,
|
|
3294
|
-
result:
|
|
3295
|
-
returnCodes:
|
|
3296
|
-
errorMessage:
|
|
3926
|
+
result: lookup2.result,
|
|
3927
|
+
returnCodes: lookup2.returnCodes,
|
|
3928
|
+
errorMessage: lookup2.errorMessage,
|
|
3297
3929
|
runAt: /* @__PURE__ */ new Date()
|
|
3298
3930
|
}
|
|
3299
3931
|
},
|
|
@@ -3326,6 +3958,10 @@ function collectTargets(ctx, cfg) {
|
|
|
3326
3958
|
ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
|
|
3327
3959
|
};
|
|
3328
3960
|
}
|
|
3961
|
+
function spamhausQueryHost(listHost, dqsKey) {
|
|
3962
|
+
const zone = dqsKey ? /^([a-z0-9-]+)\.spamhaus\.org$/i.exec(listHost)?.[1] : void 0;
|
|
3963
|
+
return zone ? `${dqsKey}.${zone}.dq.spamhaus.net` : listHost;
|
|
3964
|
+
}
|
|
3329
3965
|
function buildQueryName(target, kind, listHost) {
|
|
3330
3966
|
if (kind === "ip") {
|
|
3331
3967
|
const v = net__default.default.isIP(target);
|
|
@@ -3361,16 +3997,22 @@ async function queryDnsbl(resolver, query) {
|
|
|
3361
3997
|
};
|
|
3362
3998
|
}
|
|
3363
3999
|
}
|
|
4000
|
+
var REFUSAL_REASONS = {
|
|
4001
|
+
"127.255.255.252": "malformed query name for this list",
|
|
4002
|
+
"127.255.255.254": "queried through a public or shared DNS resolver, which Spamhaus blocks. Set dnsbl.spamhausDqsKey (free Spamhaus DQS key) or use a private resolver",
|
|
4003
|
+
"127.255.255.255": "this resolver has exceeded the free query limit"
|
|
4004
|
+
};
|
|
3364
4005
|
function interpretRecords(records) {
|
|
3365
4006
|
if (!records || records.length === 0) {
|
|
3366
4007
|
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
3367
4008
|
}
|
|
3368
4009
|
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
3369
4010
|
if (errorish.length === records.length) {
|
|
4011
|
+
const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
|
|
3370
4012
|
return {
|
|
3371
4013
|
result: "error",
|
|
3372
4014
|
returnCodes: records,
|
|
3373
|
-
errorMessage: `list
|
|
4015
|
+
errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
|
|
3374
4016
|
};
|
|
3375
4017
|
}
|
|
3376
4018
|
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
@@ -3937,17 +4579,17 @@ function parseDmarcReport(xml) {
|
|
|
3937
4579
|
const day = new Date(midMs).toISOString().slice(0, 10);
|
|
3938
4580
|
for (const rec of records) {
|
|
3939
4581
|
const row = rec?.row ?? {};
|
|
3940
|
-
const
|
|
3941
|
-
totalMessages +=
|
|
4582
|
+
const count2 = Number(row.count ?? 0) || 0;
|
|
4583
|
+
totalMessages += count2;
|
|
3942
4584
|
const evald = row.policy_evaluated ?? {};
|
|
3943
4585
|
const dkim = evald.dkim ?? "none";
|
|
3944
4586
|
const spf = evald.spf ?? "none";
|
|
3945
4587
|
const aligned = dkim === "pass" || spf === "pass";
|
|
3946
4588
|
if (aligned) {
|
|
3947
|
-
passCount +=
|
|
4589
|
+
passCount += count2;
|
|
3948
4590
|
continue;
|
|
3949
4591
|
}
|
|
3950
|
-
failCount +=
|
|
4592
|
+
failCount += count2;
|
|
3951
4593
|
const sourceIp = String(row.source_ip ?? "").trim();
|
|
3952
4594
|
if (!sourceIp) continue;
|
|
3953
4595
|
const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
|
|
@@ -3956,7 +4598,7 @@ function parseDmarcReport(xml) {
|
|
|
3956
4598
|
reportId,
|
|
3957
4599
|
domain,
|
|
3958
4600
|
sourceIp,
|
|
3959
|
-
count,
|
|
4601
|
+
count: count2,
|
|
3960
4602
|
headerFrom,
|
|
3961
4603
|
dkimResult: dkim,
|
|
3962
4604
|
spfResult: spf,
|
|
@@ -4101,11 +4743,11 @@ function suggestPolicyProgression(input) {
|
|
|
4101
4743
|
return null;
|
|
4102
4744
|
}
|
|
4103
4745
|
if (currentPolicy === "quarantine") {
|
|
4104
|
-
const
|
|
4746
|
+
const pct2 = currentPct ?? 100;
|
|
4105
4747
|
if (alignmentRate < 0.995) return null;
|
|
4106
|
-
if (
|
|
4107
|
-
if (
|
|
4108
|
-
if (
|
|
4748
|
+
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.` };
|
|
4749
|
+
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.` };
|
|
4750
|
+
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
4751
|
if (alignmentRate >= 0.999) {
|
|
4110
4752
|
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
4753
|
}
|
|
@@ -4416,6 +5058,9 @@ async function runTick(ctx) {
|
|
|
4416
5058
|
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
4417
5059
|
console.error("mailery: stalled-broadcast resume failed", err);
|
|
4418
5060
|
});
|
|
5061
|
+
await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
|
|
5062
|
+
console.error("mailery: broadcast stop-rule sweep failed", err);
|
|
5063
|
+
});
|
|
4419
5064
|
await evaluateHealth(ctx).catch((err) => {
|
|
4420
5065
|
console.error("mailery: health evaluation failed", err);
|
|
4421
5066
|
});
|
|
@@ -5817,7 +6462,7 @@ async function checkDnsbl(mailer) {
|
|
|
5817
6462
|
label: "DNS block-list checks",
|
|
5818
6463
|
severity: "warn",
|
|
5819
6464
|
message: `all ${docs.length} checks errored`,
|
|
5820
|
-
hint: "Lookups against block lists are failing \u2014 usually a DNS-resolution problem on the host running mailery, or a list
|
|
6465
|
+
hint: "Lookups against block lists are failing \u2014 usually a DNS-resolution problem on the host running mailery, or a list refusing your public resolver (Spamhaus answers 127.255.255.254; set dnsbl.spamhausDqsKey). Verify outbound DNS works."
|
|
5821
6466
|
};
|
|
5822
6467
|
}
|
|
5823
6468
|
return {
|
|
@@ -6123,7 +6768,7 @@ function humanDuration(ms) {
|
|
|
6123
6768
|
}
|
|
6124
6769
|
|
|
6125
6770
|
// src/server/runner/hygiene.ts
|
|
6126
|
-
var
|
|
6771
|
+
var DAY_MS2 = 864e5;
|
|
6127
6772
|
var HYGIENE_BUCKETS = [
|
|
6128
6773
|
{ label: "Engaged (last 30 days)", maxDays: 30 },
|
|
6129
6774
|
{ label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
|
|
@@ -6136,7 +6781,7 @@ async function computeListHygiene(ctx, opts = {}) {
|
|
|
6136
6781
|
const now = opts.now ?? Date.now();
|
|
6137
6782
|
const sunsetDays = opts.sunsetThresholdDays ?? 180;
|
|
6138
6783
|
const recentDays = opts.recentWindowDays ?? 90;
|
|
6139
|
-
const recentCutoff = new Date(now - recentDays *
|
|
6784
|
+
const recentCutoff = new Date(now - recentDays * DAY_MS2);
|
|
6140
6785
|
const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
|
|
6141
6786
|
const subscribedIds = /* @__PURE__ */ new Set();
|
|
6142
6787
|
{
|
|
@@ -6209,11 +6854,11 @@ async function computeListHygiene(ctx, opts = {}) {
|
|
|
6209
6854
|
if (lastEngaged == null) {
|
|
6210
6855
|
const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
|
|
6211
6856
|
buckets[5].count++;
|
|
6212
|
-
if (firstSent != null && now - firstSent > sunsetDays *
|
|
6857
|
+
if (firstSent != null && now - firstSent > sunsetDays * DAY_MS2) {
|
|
6213
6858
|
isSunset = true;
|
|
6214
6859
|
}
|
|
6215
6860
|
} else {
|
|
6216
|
-
const daysSince = (now - lastEngaged) /
|
|
6861
|
+
const daysSince = (now - lastEngaged) / DAY_MS2;
|
|
6217
6862
|
if (daysSince <= 30) buckets[0].count++;
|
|
6218
6863
|
else if (daysSince <= 60) buckets[1].count++;
|
|
6219
6864
|
else if (daysSince <= 90) buckets[2].count++;
|
|
@@ -6412,6 +7057,462 @@ async function evaluateMailTesterGate(ctx, input) {
|
|
|
6412
7057
|
}
|
|
6413
7058
|
return { allowed: true, reason: null, score: cached, code: null };
|
|
6414
7059
|
}
|
|
7060
|
+
var BroadcastOperationError = class extends Error {
|
|
7061
|
+
constructor(code, message, status = 400, details) {
|
|
7062
|
+
super(message);
|
|
7063
|
+
this.code = code;
|
|
7064
|
+
this.status = status;
|
|
7065
|
+
this.details = details;
|
|
7066
|
+
this.name = "BroadcastOperationError";
|
|
7067
|
+
}
|
|
7068
|
+
code;
|
|
7069
|
+
status;
|
|
7070
|
+
details;
|
|
7071
|
+
};
|
|
7072
|
+
var DEFAULT_SEGMENT = {
|
|
7073
|
+
filters: [{ kind: "subscriptionStatus", equals: "subscribed" }]
|
|
7074
|
+
};
|
|
7075
|
+
var MAX_RECIPIENT_CAP = 1e7;
|
|
7076
|
+
var ORDER_FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
7077
|
+
var recipientCapSchema = zod.z.number().int().positive().max(MAX_RECIPIENT_CAP).nullable();
|
|
7078
|
+
var orderSchema = zod.z.object({
|
|
7079
|
+
field: zod.z.string().max(128).regex(ORDER_FIELD_RE, "must be a plain field path, e.g. updatedAt"),
|
|
7080
|
+
direction: zod.z.enum(["asc", "desc"])
|
|
7081
|
+
}).nullable();
|
|
7082
|
+
var pctSchema = zod.z.number().min(0).max(100);
|
|
7083
|
+
var stopRulesSchema = zod.z.object({
|
|
7084
|
+
enabled: zod.z.boolean().optional(),
|
|
7085
|
+
hardBounceRatePct: pctSchema.optional(),
|
|
7086
|
+
complaintRatePct: pctSchema.optional(),
|
|
7087
|
+
unsubscribeRatePct: pctSchema.optional(),
|
|
7088
|
+
minSample: zod.z.number().int().min(1).max(1e6).optional()
|
|
7089
|
+
}).strict().nullable();
|
|
7090
|
+
var agentCreateBroadcastSchema = zod.z.object({
|
|
7091
|
+
slug: slugSchema,
|
|
7092
|
+
name: zod.z.string().min(1).max(200),
|
|
7093
|
+
templateSlug: slugSchema,
|
|
7094
|
+
segmentDefinition: zod.z.object({ filters: zod.z.array(zod.z.any()) }).optional(),
|
|
7095
|
+
respectRecipientTimezone: zod.z.boolean().optional(),
|
|
7096
|
+
recipientCap: recipientCapSchema.optional(),
|
|
7097
|
+
order: orderSchema.optional(),
|
|
7098
|
+
stopRules: stopRulesSchema.optional()
|
|
7099
|
+
});
|
|
7100
|
+
var agentPatchBroadcastSchema = zod.z.object({
|
|
7101
|
+
name: zod.z.string().min(1).max(200).optional(),
|
|
7102
|
+
templateSlug: slugSchema.optional(),
|
|
7103
|
+
segmentDefinition: zod.z.object({ filters: zod.z.array(zod.z.any()) }).optional(),
|
|
7104
|
+
respectRecipientTimezone: zod.z.boolean().optional(),
|
|
7105
|
+
recipientCap: recipientCapSchema.optional(),
|
|
7106
|
+
order: orderSchema.optional(),
|
|
7107
|
+
stopRules: stopRulesSchema.optional()
|
|
7108
|
+
});
|
|
7109
|
+
var agentResumeBroadcastSchema = zod.z.object({
|
|
7110
|
+
recipientCap: recipientCapSchema.optional(),
|
|
7111
|
+
stopRules: stopRulesSchema.optional(),
|
|
7112
|
+
confirmedCount: zod.z.number().int().nonnegative()
|
|
7113
|
+
});
|
|
7114
|
+
var agentPauseBroadcastSchema = zod.z.object({
|
|
7115
|
+
reason: zod.z.string().max(500).optional()
|
|
7116
|
+
});
|
|
7117
|
+
function checkWaveSettings(mailer, input) {
|
|
7118
|
+
if (input.stopRules !== void 0 && !stopRulesSchema.safeParse(input.stopRules).success) {
|
|
7119
|
+
throw new BroadcastOperationError(
|
|
7120
|
+
"invalid_stop_rules",
|
|
7121
|
+
"stopRules must be {enabled?, hardBounceRatePct?, complaintRatePct?, unsubscribeRatePct?, minSample?} (percentages 0-100), or null"
|
|
7122
|
+
);
|
|
7123
|
+
}
|
|
7124
|
+
if (input.recipientCap !== void 0 && !recipientCapSchema.safeParse(input.recipientCap).success) {
|
|
7125
|
+
throw new BroadcastOperationError("invalid_recipient_cap", `recipientCap must be a positive integer up to ${MAX_RECIPIENT_CAP}, or null`);
|
|
7126
|
+
}
|
|
7127
|
+
if (input.order !== void 0) {
|
|
7128
|
+
if (!orderSchema.safeParse(input.order).success) {
|
|
7129
|
+
throw new BroadcastOperationError("invalid_order", 'order must be {field: "<host field path>", direction: "asc" | "desc"}, or null');
|
|
7130
|
+
}
|
|
7131
|
+
if (input.order !== null && !mailer.adapter.supportsSort) {
|
|
7132
|
+
throw new BroadcastOperationError(
|
|
7133
|
+
"adapter_cannot_sort",
|
|
7134
|
+
"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",
|
|
7135
|
+
422
|
|
7136
|
+
);
|
|
7137
|
+
}
|
|
7138
|
+
}
|
|
7139
|
+
}
|
|
7140
|
+
var agentScheduleBroadcastSchema = zod.z.object({
|
|
7141
|
+
scheduledAt: zod.z.string().min(1),
|
|
7142
|
+
confirmedCount: zod.z.number().int().nonnegative(),
|
|
7143
|
+
respectRecipientTimezone: zod.z.boolean().optional()
|
|
7144
|
+
});
|
|
7145
|
+
function parseSegment(seg, strict) {
|
|
7146
|
+
const r = segmentDefinitionSchema(strict).safeParse(seg);
|
|
7147
|
+
if (!r.success) {
|
|
7148
|
+
throw new BroadcastOperationError(
|
|
7149
|
+
"invalid_segment",
|
|
7150
|
+
`segmentDefinition: ${r.error.issues.map((i) => `${i.path.join(".") || "filters"}: ${i.message}`).join("; ")}`,
|
|
7151
|
+
400
|
|
7152
|
+
);
|
|
7153
|
+
}
|
|
7154
|
+
return r.data;
|
|
7155
|
+
}
|
|
7156
|
+
function assertSubscribedSegment(seg) {
|
|
7157
|
+
const filters = Array.isArray(seg?.filters) ? seg.filters : [];
|
|
7158
|
+
const ok = filters.some((f) => f && f.kind === "subscriptionStatus" && f.equals === "subscribed");
|
|
7159
|
+
if (!ok) {
|
|
7160
|
+
throw new BroadcastOperationError(
|
|
7161
|
+
"segment_requires_subscribed",
|
|
7162
|
+
'the agent API only schedules broadcasts to subscribed contacts: add {"kind": "subscriptionStatus", "equals": "subscribed"} to segmentDefinition.filters (top level)',
|
|
7163
|
+
422
|
|
7164
|
+
);
|
|
7165
|
+
}
|
|
7166
|
+
}
|
|
7167
|
+
async function loadBroadcast(mailer, slug) {
|
|
7168
|
+
const b = await mailer.collections.broadcasts.findOne({ slug });
|
|
7169
|
+
if (!b) throw new BroadcastOperationError("not_found", `no broadcast with slug "${slug}"`, 404);
|
|
7170
|
+
return b;
|
|
7171
|
+
}
|
|
7172
|
+
async function loadBroadcastTemplate(mailer, b) {
|
|
7173
|
+
const tpl = await mailer.collections.templates.findOne({ slug: b.templateSlug });
|
|
7174
|
+
if (!tpl) {
|
|
7175
|
+
throw new BroadcastOperationError("template_not_found", `template "${b.templateSlug}" does not exist`, 409);
|
|
7176
|
+
}
|
|
7177
|
+
return tpl;
|
|
7178
|
+
}
|
|
7179
|
+
async function countRecipients(mailer, b, segment, overrides = {}) {
|
|
7180
|
+
const tpl = await loadBroadcastTemplate(mailer, b);
|
|
7181
|
+
const count2 = await countBroadcastRecipients(
|
|
7182
|
+
{
|
|
7183
|
+
_id: b._id,
|
|
7184
|
+
segmentDefinition: segment ?? b.segmentDefinition,
|
|
7185
|
+
order: b.order ?? null,
|
|
7186
|
+
recipientCap: overrides.recipientCap !== void 0 ? overrides.recipientCap : b.recipientCap ?? null
|
|
7187
|
+
},
|
|
7188
|
+
tpl.kind,
|
|
7189
|
+
mailer.getRunnerContext()
|
|
7190
|
+
);
|
|
7191
|
+
const heldSends = b._id ? await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: "held" }) : 0;
|
|
7192
|
+
return { ...count2, heldSends, templateKind: tpl.kind };
|
|
7193
|
+
}
|
|
7194
|
+
function stopRuleStatus(mailer, b, stats) {
|
|
7195
|
+
return evaluateStopRules(stats, effectiveStopRules(mailer.config, b));
|
|
7196
|
+
}
|
|
7197
|
+
async function pauseBroadcastByOperator(mailer, slug, input, actor) {
|
|
7198
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7199
|
+
if (b.status !== "sending" && b.status !== "sent") {
|
|
7200
|
+
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);
|
|
7201
|
+
}
|
|
7202
|
+
const out = await pauseBroadcast(
|
|
7203
|
+
mailer.getRunnerContext(),
|
|
7204
|
+
b._id,
|
|
7205
|
+
{ code: "manual", message: input.reason?.trim() || `paused by ${actor}`, at: /* @__PURE__ */ new Date(), details: { actor } },
|
|
7206
|
+
actor
|
|
7207
|
+
);
|
|
7208
|
+
if (!out.paused) throw new BroadcastOperationError("not_pausable", "broadcast changed state while being paused", 409);
|
|
7209
|
+
return { broadcast: await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? b, heldSends: out.heldSends };
|
|
7210
|
+
}
|
|
7211
|
+
async function createBroadcast(mailer, input, actor, opts = {}) {
|
|
7212
|
+
const { slug, name, templateSlug } = input;
|
|
7213
|
+
if (!slug || !name || !templateSlug) {
|
|
7214
|
+
throw new BroadcastOperationError("validation_failed", "slug, name, templateSlug required");
|
|
7215
|
+
}
|
|
7216
|
+
const segmentDefinition = input.segmentDefinition ? parseSegment(input.segmentDefinition, !!opts.strictSegment) : DEFAULT_SEGMENT;
|
|
7217
|
+
if (opts.requireSubscribed) assertSubscribedSegment(segmentDefinition);
|
|
7218
|
+
checkWaveSettings(mailer, input);
|
|
7219
|
+
await assertBroadcastTemplate(mailer, templateSlug, { mustExist: false });
|
|
7220
|
+
const now = /* @__PURE__ */ new Date();
|
|
7221
|
+
const doc = {
|
|
7222
|
+
slug,
|
|
7223
|
+
name,
|
|
7224
|
+
templateSlug,
|
|
7225
|
+
segmentDefinition,
|
|
7226
|
+
status: "draft",
|
|
7227
|
+
scheduledAt: null,
|
|
7228
|
+
startedAt: null,
|
|
7229
|
+
completedAt: null,
|
|
7230
|
+
confirmationRequired: true,
|
|
7231
|
+
confirmedCount: null,
|
|
7232
|
+
confirmedAt: null,
|
|
7233
|
+
confirmedBy: null,
|
|
7234
|
+
recipientCount: null,
|
|
7235
|
+
stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
|
|
7236
|
+
createdAt: now,
|
|
7237
|
+
createdBy: actor,
|
|
7238
|
+
updatedAt: now
|
|
7239
|
+
};
|
|
7240
|
+
if (input.respectRecipientTimezone) doc.respectRecipientTimezone = true;
|
|
7241
|
+
if (input.recipientCap !== void 0) doc.recipientCap = input.recipientCap;
|
|
7242
|
+
if (input.order !== void 0) doc.order = input.order;
|
|
7243
|
+
if (input.stopRules !== void 0) doc.stopRules = input.stopRules;
|
|
7244
|
+
try {
|
|
7245
|
+
const res = await mailer.collections.broadcasts.insertOne(doc);
|
|
7246
|
+
doc._id = res.insertedId;
|
|
7247
|
+
} catch (err) {
|
|
7248
|
+
if (err?.code === 11e3) throw new BroadcastOperationError("slug_taken", `slug "${slug}" is taken`, 409);
|
|
7249
|
+
throw err;
|
|
7250
|
+
}
|
|
7251
|
+
await mailer.audit({
|
|
7252
|
+
actor,
|
|
7253
|
+
action: "broadcast.create",
|
|
7254
|
+
resource: { collection: "mailer_broadcasts", id: doc._id, slug }
|
|
7255
|
+
});
|
|
7256
|
+
return doc;
|
|
7257
|
+
}
|
|
7258
|
+
async function patchBroadcast(mailer, slug, patch, actor, opts = {}) {
|
|
7259
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7260
|
+
if (b.status !== "draft") {
|
|
7261
|
+
throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be edited`, 409);
|
|
7262
|
+
}
|
|
7263
|
+
const segmentDefinition = patch.segmentDefinition ? parseSegment(patch.segmentDefinition, !!opts.strictSegment) : void 0;
|
|
7264
|
+
if (opts.requireSubscribed && segmentDefinition) assertSubscribedSegment(segmentDefinition);
|
|
7265
|
+
checkWaveSettings(mailer, patch);
|
|
7266
|
+
if (typeof patch.templateSlug === "string") await assertBroadcastTemplate(mailer, patch.templateSlug, { mustExist: false });
|
|
7267
|
+
const set2 = { updatedAt: /* @__PURE__ */ new Date() };
|
|
7268
|
+
if (patch.recipientCap !== void 0) set2.recipientCap = patch.recipientCap;
|
|
7269
|
+
if (patch.order !== void 0) set2.order = patch.order;
|
|
7270
|
+
if (patch.stopRules !== void 0) set2.stopRules = patch.stopRules;
|
|
7271
|
+
if (typeof patch.name === "string") set2.name = patch.name;
|
|
7272
|
+
if (typeof patch.templateSlug === "string") set2.templateSlug = patch.templateSlug;
|
|
7273
|
+
if (segmentDefinition) set2.segmentDefinition = segmentDefinition;
|
|
7274
|
+
if (typeof patch.respectRecipientTimezone === "boolean") set2.respectRecipientTimezone = patch.respectRecipientTimezone;
|
|
7275
|
+
const res = await mailer.collections.broadcasts.findOneAndUpdate(
|
|
7276
|
+
{ _id: b._id, status: "draft" },
|
|
7277
|
+
{ $set: set2 },
|
|
7278
|
+
{ returnDocument: "after" }
|
|
7279
|
+
);
|
|
7280
|
+
if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being edited", 409);
|
|
7281
|
+
await mailer.audit({
|
|
7282
|
+
actor,
|
|
7283
|
+
action: "broadcast.update",
|
|
7284
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug },
|
|
7285
|
+
diffSummary: Object.keys(set2).filter((k) => k !== "updatedAt").join(", ")
|
|
7286
|
+
});
|
|
7287
|
+
return res;
|
|
7288
|
+
}
|
|
7289
|
+
async function scheduleBroadcast(mailer, slug, input, actor, opts = {}) {
|
|
7290
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7291
|
+
if (b.status !== "draft") {
|
|
7292
|
+
throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be scheduled`, 409);
|
|
7293
|
+
}
|
|
7294
|
+
if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
|
|
7295
|
+
parseSegment(b.segmentDefinition, true);
|
|
7296
|
+
await assertBroadcastTemplate(mailer, b.templateSlug, { mustExist: true });
|
|
7297
|
+
if (!input.scheduledAt) throw new BroadcastOperationError("scheduledAt_required", "scheduledAt is required");
|
|
7298
|
+
const scheduled = new Date(input.scheduledAt);
|
|
7299
|
+
if (Number.isNaN(scheduled.getTime())) throw new BroadcastOperationError("bad_scheduledAt", "scheduledAt is not a date");
|
|
7300
|
+
if (typeof input.confirmedCount !== "number") {
|
|
7301
|
+
throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
|
|
7302
|
+
}
|
|
7303
|
+
const confirmedCount = input.confirmedCount;
|
|
7304
|
+
const threshold = mailer.config.broadcastConfirmationThreshold;
|
|
7305
|
+
if (opts.requireExactCount) {
|
|
7306
|
+
const { recipientCount } = await countRecipients(mailer, b);
|
|
7307
|
+
if (confirmedCount !== recipientCount) {
|
|
7308
|
+
throw new BroadcastOperationError(
|
|
7309
|
+
"count_mismatch",
|
|
7310
|
+
`confirmedCount ${confirmedCount} does not match the ${recipientCount} recipient(s) this broadcast would send to now; recount with POST /broadcasts/${b.slug}/count`,
|
|
7311
|
+
409,
|
|
7312
|
+
{ expected: recipientCount, confirmedCount }
|
|
7313
|
+
);
|
|
7314
|
+
}
|
|
7315
|
+
}
|
|
7316
|
+
const set2 = {
|
|
7317
|
+
status: "scheduled",
|
|
7318
|
+
scheduledAt: scheduled,
|
|
7319
|
+
confirmedCount,
|
|
7320
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
7321
|
+
confirmedBy: actor,
|
|
7322
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
7323
|
+
};
|
|
7324
|
+
if (input.respectRecipientTimezone) set2.respectRecipientTimezone = true;
|
|
7325
|
+
const res = await mailer.collections.broadcasts.findOneAndUpdate(
|
|
7326
|
+
{ _id: b._id, status: "draft" },
|
|
7327
|
+
{ $set: set2 },
|
|
7328
|
+
{ returnDocument: "after" }
|
|
7329
|
+
);
|
|
7330
|
+
if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being scheduled", 409);
|
|
7331
|
+
await mailer.audit({
|
|
7332
|
+
actor,
|
|
7333
|
+
action: "broadcast.schedule",
|
|
7334
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7335
|
+
diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
|
|
7336
|
+
});
|
|
7337
|
+
return res;
|
|
7338
|
+
}
|
|
7339
|
+
async function resumeBroadcast(mailer, slug, input, actor, opts = {}) {
|
|
7340
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7341
|
+
if (b.status !== "paused") {
|
|
7342
|
+
throw new BroadcastOperationError("not_paused", `broadcast is ${b.status}; only a paused broadcast can be resumed`, 409);
|
|
7343
|
+
}
|
|
7344
|
+
if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
|
|
7345
|
+
checkWaveSettings(mailer, { recipientCap: input.recipientCap, stopRules: input.stopRules });
|
|
7346
|
+
const cap = input.recipientCap !== void 0 ? input.recipientCap : b.recipientCap ?? null;
|
|
7347
|
+
const stopRules = input.stopRules === void 0 ? b.stopRules ?? null : input.stopRules === null ? null : { ...b.stopRules ?? {}, ...input.stopRules };
|
|
7348
|
+
if (b.pauseReason?.code === "stop_rule") {
|
|
7349
|
+
const evaluation = evaluateStopRules(
|
|
7350
|
+
await broadcastStatsFor(mailer.collections, b._id),
|
|
7351
|
+
effectiveStopRules(mailer.config, { stopRules })
|
|
7352
|
+
);
|
|
7353
|
+
if (evaluation.breaches.length > 0) {
|
|
7354
|
+
throw new BroadcastOperationError(
|
|
7355
|
+
"stop_rule_still_breached",
|
|
7356
|
+
`the stop rules still fire (${evaluation.breaches.map((x) => `${x.rule} ${x.ratePct}% > ${x.thresholdPct}%`).join(", ")}); investigate, then resume with adjusted stopRules to override`,
|
|
7357
|
+
409,
|
|
7358
|
+
{ evaluation }
|
|
7359
|
+
);
|
|
7360
|
+
}
|
|
7361
|
+
}
|
|
7362
|
+
if (b.pauseReason?.code === "circuit_breaker") {
|
|
7363
|
+
const tpl = await loadBroadcastTemplate(mailer, b);
|
|
7364
|
+
const bucket = await getBucketStatus(mailer.getRunnerContext(), tpl.fromEmail, tpl.kind);
|
|
7365
|
+
if (bucket?.status === "tripped") {
|
|
7366
|
+
throw new BroadcastOperationError(
|
|
7367
|
+
"circuit_breaker_tripped",
|
|
7368
|
+
`the ${bucket.senderDomain ?? "sender"} ${tpl.kind} circuit breaker is still tripped (${bucket.trippedReason ?? "no reason recorded"}); resume it first (POST /api/health/resume)`,
|
|
7369
|
+
409,
|
|
7370
|
+
{ bucket: bucket._id, trippedReason: bucket.trippedReason }
|
|
7371
|
+
);
|
|
7372
|
+
}
|
|
7373
|
+
}
|
|
7374
|
+
const sendsSoFar = await mailer.collections.sends.countDocuments({ broadcastId: b._id });
|
|
7375
|
+
if (cap !== null && cap <= sendsSoFar && b.pauseReason?.code === "cap_reached") {
|
|
7376
|
+
throw new BroadcastOperationError(
|
|
7377
|
+
"cap_not_raised",
|
|
7378
|
+
`${sendsSoFar} send(s) already count against recipientCap ${cap}; pass a higher recipientCap (or null for no cap) to send the next wave`,
|
|
7379
|
+
409,
|
|
7380
|
+
{ recipientCap: cap, sendsSoFar }
|
|
7381
|
+
);
|
|
7382
|
+
}
|
|
7383
|
+
if (cap !== null && cap < sendsSoFar) {
|
|
7384
|
+
throw new BroadcastOperationError("cap_below_sent", `recipientCap ${cap} is below the ${sendsSoFar} send(s) already made`, 400);
|
|
7385
|
+
}
|
|
7386
|
+
let expected = null;
|
|
7387
|
+
if (opts.requireExactCount) {
|
|
7388
|
+
if (typeof input.confirmedCount !== "number") {
|
|
7389
|
+
throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
|
|
7390
|
+
}
|
|
7391
|
+
const count2 = await countRecipients(mailer, b, void 0, { recipientCap: cap });
|
|
7392
|
+
expected = count2.recipientCount + count2.heldSends;
|
|
7393
|
+
if (input.confirmedCount !== expected) {
|
|
7394
|
+
throw new BroadcastOperationError(
|
|
7395
|
+
"count_mismatch",
|
|
7396
|
+
`confirmedCount ${input.confirmedCount} does not match the ${expected} send(s) resuming would release now (${count2.recipientCount} new + ${count2.heldSends} held)`,
|
|
7397
|
+
409,
|
|
7398
|
+
{ expected, recipientCount: count2.recipientCount, heldSends: count2.heldSends, confirmedCount: input.confirmedCount }
|
|
7399
|
+
);
|
|
7400
|
+
}
|
|
7401
|
+
}
|
|
7402
|
+
const now = /* @__PURE__ */ new Date();
|
|
7403
|
+
const resumed = await mailer.collections.broadcasts.findOneAndUpdate(
|
|
7404
|
+
{ _id: b._id, status: "paused" },
|
|
7405
|
+
{
|
|
7406
|
+
$set: {
|
|
7407
|
+
status: "sending",
|
|
7408
|
+
recipientCap: cap,
|
|
7409
|
+
stopRules,
|
|
7410
|
+
pausedAt: null,
|
|
7411
|
+
pauseReason: null,
|
|
7412
|
+
dispatchLeaseId: null,
|
|
7413
|
+
updatedAt: now,
|
|
7414
|
+
...typeof input.confirmedCount === "number" ? { confirmedCount: input.confirmedCount, confirmedAt: now, confirmedBy: actor } : {}
|
|
7415
|
+
}
|
|
7416
|
+
},
|
|
7417
|
+
{ returnDocument: "after" }
|
|
7418
|
+
);
|
|
7419
|
+
if (!resumed) throw new BroadcastOperationError("not_paused", "broadcast left paused while being resumed", 409);
|
|
7420
|
+
await mailer.audit({
|
|
7421
|
+
actor,
|
|
7422
|
+
action: "broadcast.resume",
|
|
7423
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7424
|
+
diffSummary: `was paused (${b.pauseReason?.code ?? "unknown"}) \xB7 recipientCap ${b.recipientCap ?? "none"} \u2192 ${cap ?? "none"} \xB7 sendsSoFar=${sendsSoFar}${expected !== null ? ` \xB7 confirmedCount=${expected}` : ""}`
|
|
7425
|
+
});
|
|
7426
|
+
const ctx = mailer.getRunnerContext();
|
|
7427
|
+
await releaseHeldSends(ctx, b._id);
|
|
7428
|
+
await startBroadcastDispatch(resumed, ctx);
|
|
7429
|
+
return await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? resumed;
|
|
7430
|
+
}
|
|
7431
|
+
async function cancelBroadcast(mailer, slug, actor) {
|
|
7432
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7433
|
+
if (b.status === "cancelled") return { broadcast: b, cancelledSends: 0 };
|
|
7434
|
+
const pending = await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: { $in: ["queued", "held"] } });
|
|
7435
|
+
if ((b.status === "sent" || b.status === "failed") && pending === 0) {
|
|
7436
|
+
throw new BroadcastOperationError("already_finished", `broadcast is ${b.status} with nothing left to send; there is nothing to cancel`, 409);
|
|
7437
|
+
}
|
|
7438
|
+
await mailer.collections.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
|
|
7439
|
+
const now = /* @__PURE__ */ new Date();
|
|
7440
|
+
const cancelled = await mailer.collections.sends.updateMany(
|
|
7441
|
+
{ broadcastId: b._id, status: { $in: ["queued", "held"] } },
|
|
7442
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: broadcast cancelled by ${actor}`, updatedAt: now } }
|
|
7443
|
+
);
|
|
7444
|
+
await mailer.audit({
|
|
7445
|
+
actor,
|
|
7446
|
+
action: "broadcast.cancel",
|
|
7447
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7448
|
+
diffSummary: `was ${b.status}; cancelled ${cancelled.modifiedCount} queued/held send(s)`
|
|
7449
|
+
});
|
|
7450
|
+
const after = await mailer.collections.broadcasts.findOne({ _id: b._id });
|
|
7451
|
+
return { broadcast: after ?? b, cancelledSends: cancelled.modifiedCount };
|
|
7452
|
+
}
|
|
7453
|
+
async function assertBroadcastTemplate(mailer, templateSlug, opts) {
|
|
7454
|
+
const tpl = await mailer.collections.templates.findOne({ slug: templateSlug });
|
|
7455
|
+
if (!tpl) {
|
|
7456
|
+
if (opts.mustExist) throw new BroadcastOperationError("template_not_found", `template "${templateSlug}" does not exist`, 409);
|
|
7457
|
+
return;
|
|
7458
|
+
}
|
|
7459
|
+
if (tpl.kind !== "marketing") {
|
|
7460
|
+
throw new BroadcastOperationError(
|
|
7461
|
+
"template_not_marketing",
|
|
7462
|
+
`template "${templateSlug}" is ${tpl.kind}; a broadcast needs a marketing template (List-Unsubscribe, marketing opt-outs, circuit breaker)`,
|
|
7463
|
+
409
|
|
7464
|
+
);
|
|
7465
|
+
}
|
|
7466
|
+
if (opts.mustExist && !tpl.body?.html && !tpl.body?.mjml) {
|
|
7467
|
+
throw new BroadcastOperationError("template_not_published", `template "${templateSlug}" has no published body`, 409);
|
|
7468
|
+
}
|
|
7469
|
+
}
|
|
7470
|
+
async function computeBroadcastStats(mailer, idFilter) {
|
|
7471
|
+
return aggregateBroadcastStats(mailer.collections, idFilter);
|
|
7472
|
+
}
|
|
7473
|
+
function capProgress(b, stats) {
|
|
7474
|
+
const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
|
|
7475
|
+
return {
|
|
7476
|
+
recipientCap: cap,
|
|
7477
|
+
sendsSoFar: stats.total,
|
|
7478
|
+
remaining: cap === null ? null : Math.max(0, cap - stats.total)
|
|
7479
|
+
};
|
|
7480
|
+
}
|
|
7481
|
+
async function broadcastStatusBreakdown(mailer, broadcastId) {
|
|
7482
|
+
const rows = await mailer.collections.sends.aggregate([
|
|
7483
|
+
{ $match: { broadcastId } },
|
|
7484
|
+
{ $group: { _id: "$status", n: { $sum: 1 } } }
|
|
7485
|
+
]).toArray();
|
|
7486
|
+
return Object.fromEntries(rows.map((r) => [r._id, r.n]));
|
|
7487
|
+
}
|
|
7488
|
+
function broadcastSummary(b) {
|
|
7489
|
+
return {
|
|
7490
|
+
id: String(b._id),
|
|
7491
|
+
slug: b.slug,
|
|
7492
|
+
name: b.name,
|
|
7493
|
+
templateSlug: b.templateSlug,
|
|
7494
|
+
status: b.status,
|
|
7495
|
+
segmentDefinition: b.segmentDefinition,
|
|
7496
|
+
respectRecipientTimezone: b.respectRecipientTimezone === true,
|
|
7497
|
+
recipientCap: b.recipientCap ?? null,
|
|
7498
|
+
order: b.order ?? null,
|
|
7499
|
+
pausedAt: b.pausedAt ?? null,
|
|
7500
|
+
pauseReason: b.pauseReason ?? null,
|
|
7501
|
+
stopRules: b.stopRules ?? null,
|
|
7502
|
+
stopRuleBreach: b.stopRuleBreach ?? null,
|
|
7503
|
+
failureReason: b.failureReason ?? null,
|
|
7504
|
+
scheduledAt: b.scheduledAt,
|
|
7505
|
+
startedAt: b.startedAt,
|
|
7506
|
+
completedAt: b.completedAt,
|
|
7507
|
+
confirmedCount: b.confirmedCount,
|
|
7508
|
+
confirmedAt: b.confirmedAt,
|
|
7509
|
+
confirmedBy: b.confirmedBy,
|
|
7510
|
+
recipientCount: b.recipientCount,
|
|
7511
|
+
createdAt: b.createdAt,
|
|
7512
|
+
createdBy: b.createdBy,
|
|
7513
|
+
updatedAt: b.updatedAt
|
|
7514
|
+
};
|
|
7515
|
+
}
|
|
6415
7516
|
|
|
6416
7517
|
// src/server/api/admin.ts
|
|
6417
7518
|
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 +8315,19 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7214
8315
|
const flow = await c.flows.findOne({ slug: req.params.slug });
|
|
7215
8316
|
if (!flow) return res.status(404).json({ error: "not_found" });
|
|
7216
8317
|
const { steps, notes, trigger, name, description, goal, audience } = req.body ?? {};
|
|
7217
|
-
const
|
|
8318
|
+
const set2 = {
|
|
7218
8319
|
"draft.lastModifiedBy": req.actor,
|
|
7219
8320
|
"draft.lastModifiedAt": /* @__PURE__ */ new Date(),
|
|
7220
8321
|
updatedAt: /* @__PURE__ */ new Date()
|
|
7221
8322
|
};
|
|
7222
|
-
if (Array.isArray(steps))
|
|
7223
|
-
if (typeof notes === "string")
|
|
7224
|
-
if (trigger?.eventName)
|
|
7225
|
-
if (typeof name === "string")
|
|
7226
|
-
if (typeof description === "string")
|
|
7227
|
-
if (typeof goal === "string")
|
|
7228
|
-
if (typeof audience === "string")
|
|
7229
|
-
await c.flows.updateOne({ _id: flow._id }, { $set:
|
|
8323
|
+
if (Array.isArray(steps)) set2["draft.steps"] = steps;
|
|
8324
|
+
if (typeof notes === "string") set2["draft.notes"] = notes;
|
|
8325
|
+
if (trigger?.eventName) set2.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
|
|
8326
|
+
if (typeof name === "string") set2.name = name;
|
|
8327
|
+
if (typeof description === "string") set2.description = description;
|
|
8328
|
+
if (typeof goal === "string") set2.goal = goal;
|
|
8329
|
+
if (typeof audience === "string") set2.audience = audience;
|
|
8330
|
+
await c.flows.updateOne({ _id: flow._id }, { $set: set2 });
|
|
7230
8331
|
await mailer.audit({
|
|
7231
8332
|
actor: req.actor,
|
|
7232
8333
|
action: "flow.draft.update",
|
|
@@ -7387,25 +8488,25 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7387
8488
|
}
|
|
7388
8489
|
);
|
|
7389
8490
|
}
|
|
7390
|
-
const
|
|
8491
|
+
const set2 = {
|
|
7391
8492
|
"draft.lastModifiedBy": req.actor,
|
|
7392
8493
|
"draft.lastModifiedAt": /* @__PURE__ */ new Date(),
|
|
7393
8494
|
updatedAt: /* @__PURE__ */ new Date()
|
|
7394
8495
|
};
|
|
7395
|
-
if (typeof subject === "string")
|
|
7396
|
-
if (typeof preheader === "string")
|
|
7397
|
-
if (typeof mjml === "string")
|
|
7398
|
-
if (typeof html === "string")
|
|
7399
|
-
if (editorJson !== void 0)
|
|
7400
|
-
if (typeof notes === "string")
|
|
7401
|
-
if (typeof name === "string")
|
|
7402
|
-
if (typeof fromName === "string")
|
|
7403
|
-
if (typeof fromEmail === "string")
|
|
7404
|
-
if (typeof replyTo === "string" || replyTo === null)
|
|
7405
|
-
if (kind === "marketing" || kind === "transactional")
|
|
8496
|
+
if (typeof subject === "string") set2["draft.subject"] = subject;
|
|
8497
|
+
if (typeof preheader === "string") set2["draft.preheader"] = preheader;
|
|
8498
|
+
if (typeof mjml === "string") set2["draft.mjml"] = mjml;
|
|
8499
|
+
if (typeof html === "string") set2["draft.html"] = html;
|
|
8500
|
+
if (editorJson !== void 0) set2["draft.editorJson"] = editorJson;
|
|
8501
|
+
if (typeof notes === "string") set2["draft.notes"] = notes;
|
|
8502
|
+
if (typeof name === "string") set2.name = name;
|
|
8503
|
+
if (typeof fromName === "string") set2.fromName = fromName;
|
|
8504
|
+
if (typeof fromEmail === "string") set2.fromEmail = fromEmail;
|
|
8505
|
+
if (typeof replyTo === "string" || replyTo === null) set2.replyTo = replyTo;
|
|
8506
|
+
if (kind === "marketing" || kind === "transactional") set2.kind = kind;
|
|
7406
8507
|
if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
|
|
7407
|
-
const resultingKind =
|
|
7408
|
-
const resultingFromEmail =
|
|
8508
|
+
const resultingKind = set2.kind ?? tpl.kind;
|
|
8509
|
+
const resultingFromEmail = set2.fromEmail ?? tpl.fromEmail;
|
|
7409
8510
|
const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
|
|
7410
8511
|
if (!senderCheck.ok) {
|
|
7411
8512
|
return res.status(400).json({
|
|
@@ -7415,10 +8516,10 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7415
8516
|
});
|
|
7416
8517
|
}
|
|
7417
8518
|
}
|
|
7418
|
-
if (TEMPLATE_BODY_FORMATS.includes(bodyFormat))
|
|
7419
|
-
if (typeof trackOpens === "boolean")
|
|
7420
|
-
if (typeof trackClicks === "boolean")
|
|
7421
|
-
await c.templates.updateOne({ _id: tpl._id }, { $set:
|
|
8519
|
+
if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set2.bodyFormat = bodyFormat;
|
|
8520
|
+
if (typeof trackOpens === "boolean") set2.trackOpens = trackOpens;
|
|
8521
|
+
if (typeof trackClicks === "boolean") set2.trackClicks = trackClicks;
|
|
8522
|
+
await c.templates.updateOne({ _id: tpl._id }, { $set: set2 });
|
|
7422
8523
|
await mailer.audit({
|
|
7423
8524
|
actor: req.actor,
|
|
7424
8525
|
action: "template.draft.update",
|
|
@@ -7896,125 +8997,60 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7896
8997
|
);
|
|
7897
8998
|
r.post(
|
|
7898
8999
|
"/broadcasts",
|
|
7899
|
-
|
|
7900
|
-
const { slug, name, templateSlug, segmentDefinition } = req.body ?? {};
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
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
|
-
});
|
|
9000
|
+
broadcastHandler(async (req, res) => {
|
|
9001
|
+
const { slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
|
|
9002
|
+
await createBroadcast(
|
|
9003
|
+
mailer,
|
|
9004
|
+
{ slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
|
|
9005
|
+
req.actor
|
|
9006
|
+
);
|
|
7934
9007
|
return res.json({ ok: true, slug });
|
|
7935
9008
|
})
|
|
7936
9009
|
);
|
|
7937
9010
|
r.patch(
|
|
7938
9011
|
"/broadcasts/:slug",
|
|
7939
|
-
|
|
7940
|
-
const
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
if (segmentDefinition) set.segmentDefinition = segmentDefinition;
|
|
7948
|
-
await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
|
|
9012
|
+
broadcastHandler(async (req, res) => {
|
|
9013
|
+
const { name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
|
|
9014
|
+
await patchBroadcast(
|
|
9015
|
+
mailer,
|
|
9016
|
+
String(req.params.slug),
|
|
9017
|
+
{ name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
|
|
9018
|
+
req.actor
|
|
9019
|
+
);
|
|
7949
9020
|
return res.json({ ok: true });
|
|
7950
9021
|
})
|
|
7951
9022
|
);
|
|
7952
9023
|
r.post(
|
|
7953
9024
|
"/broadcasts/:slug/segment/count",
|
|
7954
|
-
|
|
7955
|
-
const
|
|
7956
|
-
if (!
|
|
7957
|
-
const
|
|
7958
|
-
const
|
|
7959
|
-
|
|
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);
|
|
9025
|
+
broadcastHandler(async (req, res) => {
|
|
9026
|
+
const raw = req.body?.segmentDefinition;
|
|
9027
|
+
if (!raw?.filters) return res.status(400).json({ error: "segment_required" });
|
|
9028
|
+
const segment = parseSegment(raw, false);
|
|
9029
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
9030
|
+
const count2 = await countRecipients(mailer, b, segment);
|
|
7967
9031
|
return res.json({
|
|
7968
|
-
upperBound,
|
|
7969
|
-
approximate:
|
|
7970
|
-
computedMs:
|
|
9032
|
+
upperBound: count2.recipientCount,
|
|
9033
|
+
approximate: false,
|
|
9034
|
+
computedMs: count2.computedMs,
|
|
9035
|
+
hostMatched: count2.hostMatched,
|
|
9036
|
+
eligible: count2.eligible,
|
|
9037
|
+
alreadySent: count2.alreadySent,
|
|
9038
|
+
recipientCount: count2.recipientCount
|
|
7971
9039
|
});
|
|
7972
9040
|
})
|
|
7973
9041
|
);
|
|
7974
9042
|
r.post(
|
|
7975
9043
|
"/broadcasts/:slug/schedule",
|
|
7976
|
-
|
|
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" });
|
|
9044
|
+
broadcastHandler(async (req, res) => {
|
|
7980
9045
|
const { scheduledAt, confirmedCount, respectRecipientTimezone } = req.body ?? {};
|
|
7981
|
-
|
|
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
|
-
});
|
|
9046
|
+
await scheduleBroadcast(mailer, String(req.params.slug), { scheduledAt, confirmedCount, respectRecipientTimezone }, req.actor);
|
|
8004
9047
|
return res.json({ ok: true });
|
|
8005
9048
|
})
|
|
8006
9049
|
);
|
|
8007
9050
|
r.post(
|
|
8008
9051
|
"/broadcasts/:slug/cancel",
|
|
8009
|
-
|
|
8010
|
-
|
|
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
|
-
});
|
|
9052
|
+
broadcastHandler(async (req, res) => {
|
|
9053
|
+
await cancelBroadcast(mailer, String(req.params.slug), req.actor);
|
|
8018
9054
|
return res.json({ ok: true });
|
|
8019
9055
|
})
|
|
8020
9056
|
);
|
|
@@ -8151,35 +9187,15 @@ async function computeTemplateStats(mailer, slugFilter) {
|
|
|
8151
9187
|
}
|
|
8152
9188
|
return out;
|
|
8153
9189
|
}
|
|
8154
|
-
function
|
|
8155
|
-
return
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
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] } }
|
|
9190
|
+
function broadcastHandler(fn) {
|
|
9191
|
+
return (req, res, next) => {
|
|
9192
|
+
fn(req, res, next).catch((err) => {
|
|
9193
|
+
if (err instanceof BroadcastOperationError) {
|
|
9194
|
+
return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
|
|
8170
9195
|
}
|
|
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
|
|
9196
|
+
next(err);
|
|
8180
9197
|
});
|
|
8181
|
-
}
|
|
8182
|
-
return out;
|
|
9198
|
+
};
|
|
8183
9199
|
}
|
|
8184
9200
|
|
|
8185
9201
|
// src/server/api/wrap.ts
|
|
@@ -8556,7 +9572,16 @@ function unitToMs2(value, unit) {
|
|
|
8556
9572
|
}
|
|
8557
9573
|
|
|
8558
9574
|
// src/server/api/agent.ts
|
|
8559
|
-
var VERSION = "0.
|
|
9575
|
+
var VERSION = "0.19.0" ;
|
|
9576
|
+
var broadcastCountSchema = zod.z.object({
|
|
9577
|
+
recipientCap: zod.z.number().int().positive().max(1e7).nullable().optional()
|
|
9578
|
+
});
|
|
9579
|
+
var broadcastTestSendSchema = zod.z.object({
|
|
9580
|
+
contactIds: zod.z.array(zod.z.string().min(1).max(256)).min(1).max(10),
|
|
9581
|
+
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
|
|
9582
|
+
/** 'queue' leaves the sends queued for the worker instead of dispatching inline. */
|
|
9583
|
+
dispatch: zod.z.enum(["now", "queue"]).optional()
|
|
9584
|
+
});
|
|
8560
9585
|
var agentTagsInputSchema = zod.z.object({
|
|
8561
9586
|
add: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([]),
|
|
8562
9587
|
remove: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([])
|
|
@@ -8821,7 +9846,7 @@ function createAgentRouter(mailer, opts) {
|
|
|
8821
9846
|
}
|
|
8822
9847
|
const now = /* @__PURE__ */ new Date();
|
|
8823
9848
|
const publishedBy = input.publishedBy ?? actorOf(req);
|
|
8824
|
-
const
|
|
9849
|
+
const set2 = {
|
|
8825
9850
|
slug,
|
|
8826
9851
|
name: input.name,
|
|
8827
9852
|
description: input.description,
|
|
@@ -8852,7 +9877,7 @@ function createAgentRouter(mailer, opts) {
|
|
|
8852
9877
|
const result = await c.templates.updateOne(
|
|
8853
9878
|
{ slug },
|
|
8854
9879
|
{
|
|
8855
|
-
$set:
|
|
9880
|
+
$set: set2,
|
|
8856
9881
|
$setOnInsert: {
|
|
8857
9882
|
createdAt: now,
|
|
8858
9883
|
stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null }
|
|
@@ -9234,6 +10259,152 @@ function createAgentRouter(mailer, opts) {
|
|
|
9234
10259
|
res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
|
|
9235
10260
|
})
|
|
9236
10261
|
);
|
|
10262
|
+
router.get(
|
|
10263
|
+
"/broadcasts",
|
|
10264
|
+
wrap2(async (_req, res) => {
|
|
10265
|
+
const docs = await c.broadcasts.find().sort({ createdAt: -1 }).limit(200).toArray();
|
|
10266
|
+
const stats = await computeBroadcastStats(mailer);
|
|
10267
|
+
res.json(docs.map((b) => ({ ...broadcastSummary(b), stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
|
|
10268
|
+
})
|
|
10269
|
+
);
|
|
10270
|
+
router.get(
|
|
10271
|
+
"/broadcasts/:slug",
|
|
10272
|
+
wrap2(async (req, res) => {
|
|
10273
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
10274
|
+
const [statsMap, statusBreakdown] = await Promise.all([
|
|
10275
|
+
computeBroadcastStats(mailer, b._id),
|
|
10276
|
+
broadcastStatusBreakdown(mailer, b._id)
|
|
10277
|
+
]);
|
|
10278
|
+
const stats = statsMap.get(String(b._id)) ?? emptyBroadcastStats();
|
|
10279
|
+
res.json({
|
|
10280
|
+
broadcast: broadcastSummary(b),
|
|
10281
|
+
stats,
|
|
10282
|
+
statusBreakdown,
|
|
10283
|
+
capProgress: capProgress(b, stats),
|
|
10284
|
+
stopRules: stopRuleStatus(mailer, b, stats)
|
|
10285
|
+
});
|
|
10286
|
+
})
|
|
10287
|
+
);
|
|
10288
|
+
router.post(
|
|
10289
|
+
"/broadcasts",
|
|
10290
|
+
wrap2(async (req, res) => {
|
|
10291
|
+
const parsed = agentCreateBroadcastSchema.safeParse(req.body ?? {});
|
|
10292
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10293
|
+
const b = await createBroadcast(mailer, parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
|
|
10294
|
+
res.status(201).json({ broadcast: broadcastSummary(b) });
|
|
10295
|
+
})
|
|
10296
|
+
);
|
|
10297
|
+
router.patch(
|
|
10298
|
+
"/broadcasts/:slug",
|
|
10299
|
+
wrap2(async (req, res) => {
|
|
10300
|
+
const parsed = agentPatchBroadcastSchema.safeParse(req.body ?? {});
|
|
10301
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10302
|
+
const b = await patchBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
|
|
10303
|
+
res.json({ broadcast: broadcastSummary(b) });
|
|
10304
|
+
})
|
|
10305
|
+
);
|
|
10306
|
+
router.post(
|
|
10307
|
+
"/broadcasts/:slug/schedule",
|
|
10308
|
+
wrap2(async (req, res) => {
|
|
10309
|
+
const parsed = agentScheduleBroadcastSchema.safeParse(req.body ?? {});
|
|
10310
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10311
|
+
const b = await scheduleBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
|
|
10312
|
+
requireSubscribed: true,
|
|
10313
|
+
requireExactCount: true
|
|
10314
|
+
});
|
|
10315
|
+
res.json({ broadcast: broadcastSummary(b) });
|
|
10316
|
+
})
|
|
10317
|
+
);
|
|
10318
|
+
router.post(
|
|
10319
|
+
"/broadcasts/:slug/count",
|
|
10320
|
+
wrap2(async (req, res) => {
|
|
10321
|
+
const parsed = broadcastCountSchema.safeParse(req.body ?? {});
|
|
10322
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10323
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
10324
|
+
const count2 = await countRecipients(
|
|
10325
|
+
mailer,
|
|
10326
|
+
b,
|
|
10327
|
+
void 0,
|
|
10328
|
+
parsed.data.recipientCap !== void 0 ? { recipientCap: parsed.data.recipientCap } : {}
|
|
10329
|
+
);
|
|
10330
|
+
res.json({
|
|
10331
|
+
slug: b.slug,
|
|
10332
|
+
status: b.status,
|
|
10333
|
+
...count2,
|
|
10334
|
+
confirmationThreshold: mailer.config.broadcastConfirmationThreshold
|
|
10335
|
+
});
|
|
10336
|
+
})
|
|
10337
|
+
);
|
|
10338
|
+
router.post(
|
|
10339
|
+
"/broadcasts/:slug/test-send",
|
|
10340
|
+
wrap2(async (req, res) => {
|
|
10341
|
+
const parsed = broadcastTestSendSchema.safeParse(req.body ?? {});
|
|
10342
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10343
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
10344
|
+
const tpl = await loadBroadcastTemplate(mailer, b);
|
|
10345
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
10346
|
+
return res.status(409).json({ error: "not_published", message: `template "${tpl.slug}" has no published body` });
|
|
10347
|
+
}
|
|
10348
|
+
const contacts = [];
|
|
10349
|
+
for (const id of parsed.data.contactIds) {
|
|
10350
|
+
const contact = await loadContact(res, id);
|
|
10351
|
+
if (!contact) return;
|
|
10352
|
+
if (!guardTestContact(res, contact)) return;
|
|
10353
|
+
contacts.push(contact);
|
|
10354
|
+
}
|
|
10355
|
+
const ctx = mailer.getRunnerContext();
|
|
10356
|
+
const dispatchNow = parsed.data.dispatch !== "queue";
|
|
10357
|
+
const sends = [];
|
|
10358
|
+
for (const contact of contacts) {
|
|
10359
|
+
const { sendId } = await mailer.sendOneOff({
|
|
10360
|
+
templateSlug: tpl.slug,
|
|
10361
|
+
externalId: contact.externalId,
|
|
10362
|
+
dedupeKey: `broadcast-test:${b._id}:${contact.externalId}:${crypto2__default.default.randomUUID()}`,
|
|
10363
|
+
vars: parsed.data.vars
|
|
10364
|
+
});
|
|
10365
|
+
const _id = new mongodb.ObjectId(sendId);
|
|
10366
|
+
await c.sends.updateOne({ _id }, { $set: { manualSendBy: `broadcast-test:${b.slug}` } });
|
|
10367
|
+
if (dispatchNow) await dispatchSend(_id, ctx);
|
|
10368
|
+
const row = await c.sends.findOne({ _id });
|
|
10369
|
+
if (row) sends.push(sendSummary(row));
|
|
10370
|
+
}
|
|
10371
|
+
await mailer.audit({
|
|
10372
|
+
actor: actorOf(req),
|
|
10373
|
+
action: "agent.broadcast.test-send",
|
|
10374
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
10375
|
+
diffSummary: `template=${tpl.slug} to=${contacts.map((x) => x.email).join(", ")} dispatch=${dispatchNow ? "now" : "queue"}`
|
|
10376
|
+
});
|
|
10377
|
+
res.status(201).json({ broadcast: { slug: b.slug, status: b.status }, templateSlug: tpl.slug, dispatched: dispatchNow, sends });
|
|
10378
|
+
})
|
|
10379
|
+
);
|
|
10380
|
+
router.post(
|
|
10381
|
+
"/broadcasts/:slug/pause",
|
|
10382
|
+
wrap2(async (req, res) => {
|
|
10383
|
+
const parsed = agentPauseBroadcastSchema.safeParse(req.body ?? {});
|
|
10384
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10385
|
+
const out = await pauseBroadcastByOperator(mailer, String(req.params.slug), parsed.data, actorOf(req));
|
|
10386
|
+
res.json({ broadcast: broadcastSummary(out.broadcast), heldSends: out.heldSends });
|
|
10387
|
+
})
|
|
10388
|
+
);
|
|
10389
|
+
router.post(
|
|
10390
|
+
"/broadcasts/:slug/resume",
|
|
10391
|
+
wrap2(async (req, res) => {
|
|
10392
|
+
const parsed = agentResumeBroadcastSchema.safeParse(req.body ?? {});
|
|
10393
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10394
|
+
const b = await resumeBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
|
|
10395
|
+
requireSubscribed: true,
|
|
10396
|
+
requireExactCount: true
|
|
10397
|
+
});
|
|
10398
|
+
res.json({ broadcast: broadcastSummary(b) });
|
|
10399
|
+
})
|
|
10400
|
+
);
|
|
10401
|
+
router.post(
|
|
10402
|
+
"/broadcasts/:slug/cancel",
|
|
10403
|
+
wrap2(async (req, res) => {
|
|
10404
|
+
const out = await cancelBroadcast(mailer, String(req.params.slug), actorOf(req));
|
|
10405
|
+
res.json({ broadcast: broadcastSummary(out.broadcast), cancelledSends: out.cancelledSends });
|
|
10406
|
+
})
|
|
10407
|
+
);
|
|
9237
10408
|
router.post(
|
|
9238
10409
|
"/tick",
|
|
9239
10410
|
wrap2(async (_req, res) => {
|
|
@@ -9323,6 +10494,9 @@ function createAgentRouter(mailer, opts) {
|
|
|
9323
10494
|
if (err instanceof FlowOperationError) {
|
|
9324
10495
|
return res.status(err.status).json({ error: err.code, message: err.message });
|
|
9325
10496
|
}
|
|
10497
|
+
if (err instanceof BroadcastOperationError) {
|
|
10498
|
+
return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
|
|
10499
|
+
}
|
|
9326
10500
|
if (err?.name === "ZodError") {
|
|
9327
10501
|
return res.status(400).json({ error: "validation_failed", issues: err.issues });
|
|
9328
10502
|
}
|
|
@@ -9691,6 +10865,9 @@ function runSummary(r) {
|
|
|
9691
10865
|
history: r.history
|
|
9692
10866
|
};
|
|
9693
10867
|
}
|
|
10868
|
+
function zodMessage(error) {
|
|
10869
|
+
return error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ");
|
|
10870
|
+
}
|
|
9694
10871
|
function objectOrUndefined(v) {
|
|
9695
10872
|
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
9696
10873
|
}
|
|
@@ -9729,6 +10906,16 @@ var ENDPOINTS = [
|
|
|
9729
10906
|
{ method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
|
|
9730
10907
|
{ 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
10908
|
{ 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 },
|
|
10909
|
+
{ method: "GET", path: "/broadcasts", summary: "Every broadcast (newest first, up to 200) with its stats." },
|
|
10910
|
+
{ 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." },
|
|
10911
|
+
{ method: "POST", path: "/broadcasts", summary: "Create a draft broadcast: {slug, name, templateSlug, segmentDefinition?, respectRecipientTimezone?}." },
|
|
10912
|
+
{ method: "PATCH", path: "/broadcasts/:slug", summary: "Edit a draft broadcast (name, templateSlug, segmentDefinition, respectRecipientTimezone). 409 once it has left draft." },
|
|
10913
|
+
{ 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." },
|
|
10914
|
+
{ 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." },
|
|
10915
|
+
{ 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 },
|
|
10916
|
+
{ method: "POST", path: "/broadcasts/:slug/pause", summary: "Pause a sending broadcast by hand ({reason?}); its queued sends are held." },
|
|
10917
|
+
{ 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." },
|
|
10918
|
+
{ method: "POST", path: "/broadcasts/:slug/cancel", summary: "Cancel a broadcast." },
|
|
9732
10919
|
{ method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
|
|
9733
10920
|
{ method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
|
|
9734
10921
|
{ 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 +11156,15 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
9969
11156
|
return;
|
|
9970
11157
|
}
|
|
9971
11158
|
const now = /* @__PURE__ */ new Date();
|
|
11159
|
+
await mailer.collections.sends.updateOne(
|
|
11160
|
+
{ _id: sendId, status: "sent" },
|
|
11161
|
+
{ $set: { status: "delivered" } }
|
|
11162
|
+
);
|
|
9972
11163
|
await mailer.collections.sends.updateOne(
|
|
9973
11164
|
{ _id: sendId },
|
|
9974
11165
|
{
|
|
9975
11166
|
$set: {
|
|
9976
|
-
openedAt: send.openedAt ?? now
|
|
9977
|
-
status: "delivered"
|
|
11167
|
+
openedAt: send.openedAt ?? now
|
|
9978
11168
|
},
|
|
9979
11169
|
$inc: { openCount: 1 },
|
|
9980
11170
|
// Per-open user agent, so `hasOpenedExcludingBots` has a signal to
|
|
@@ -10113,6 +11303,11 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
10113
11303
|
}
|
|
10114
11304
|
}
|
|
10115
11305
|
res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
|
|
11306
|
+
if (!dbError && decoded.sendId) {
|
|
11307
|
+
void attributeUnsubscribeToSend(mailer.getRunnerContext(), decoded.sendId, decoded.email).catch((err) => {
|
|
11308
|
+
logger.warn?.({ err, sendId: decoded.sendId }, "mailery: unsubscribe attribution failed");
|
|
11309
|
+
});
|
|
11310
|
+
}
|
|
10116
11311
|
}));
|
|
10117
11312
|
router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
|
|
10118
11313
|
const token = req.params.token;
|
|
@@ -10444,7 +11639,7 @@ var DEDUPE_POLICIES = [
|
|
|
10444
11639
|
];
|
|
10445
11640
|
|
|
10446
11641
|
// src/server/index.ts
|
|
10447
|
-
var VERSION2 = "0.
|
|
11642
|
+
var VERSION2 = "0.19.0" ;
|
|
10448
11643
|
|
|
10449
11644
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
10450
11645
|
exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;
|