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.js
CHANGED
|
@@ -88,6 +88,42 @@ __export(mongo_exports, {
|
|
|
88
88
|
function canBeObjectId(s) {
|
|
89
89
|
return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
|
|
90
90
|
}
|
|
91
|
+
function getPath(doc, path3) {
|
|
92
|
+
let cur = doc;
|
|
93
|
+
for (const part of path3.split(".")) {
|
|
94
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
95
|
+
cur = cur[part];
|
|
96
|
+
}
|
|
97
|
+
return cur;
|
|
98
|
+
}
|
|
99
|
+
function isObjectIdLike(v) {
|
|
100
|
+
return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
|
|
101
|
+
}
|
|
102
|
+
function packScalar(v) {
|
|
103
|
+
if (v instanceof Date) return { d: v.toISOString() };
|
|
104
|
+
if (isObjectIdLike(v)) return { o: v.toHexString() };
|
|
105
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function unpackScalar(v) {
|
|
109
|
+
if (v && typeof v === "object") {
|
|
110
|
+
if ("d" in v) return new Date(v.d);
|
|
111
|
+
if ("o" in v) return new ObjectId(v.o);
|
|
112
|
+
}
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
function encodeSortCursor(value, id) {
|
|
116
|
+
return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
|
|
117
|
+
}
|
|
118
|
+
function decodeSortCursor(cursor) {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
121
|
+
if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
|
|
122
|
+
return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
91
127
|
var MongoContactAdapter;
|
|
92
128
|
var init_mongo = __esm({
|
|
93
129
|
"src/server/adapters/mongo.ts"() {
|
|
@@ -137,7 +173,10 @@ var init_mongo = __esm({
|
|
|
137
173
|
}
|
|
138
174
|
return out;
|
|
139
175
|
}
|
|
176
|
+
/** `query` honours `opts.sort` — see `querySorted`. */
|
|
177
|
+
supportsSort = true;
|
|
140
178
|
async query(filter, opts) {
|
|
179
|
+
if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
|
|
141
180
|
const query = this.translateFilterFn(filter);
|
|
142
181
|
const limit = Math.min(opts.limit, this.batchSize);
|
|
143
182
|
if (opts.cursor) {
|
|
@@ -156,6 +195,52 @@ var init_mongo = __esm({
|
|
|
156
195
|
const query = this.translateFilterFn(filter);
|
|
157
196
|
return await this.col.countDocuments(query);
|
|
158
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Keyset pagination in (sort.field, idField) order. The cursor encodes the
|
|
200
|
+
* last row's sort value and id, and the next page is every row strictly
|
|
201
|
+
* after that position. Mongo sorts a null or missing value lowest, so
|
|
202
|
+
* `desc` puts contacts without the field last and `asc` puts them first;
|
|
203
|
+
* the position predicate follows the same rule, so they are neither
|
|
204
|
+
* skipped nor repeated.
|
|
205
|
+
*
|
|
206
|
+
* The field should hold one BSON type (or be absent): range operators only
|
|
207
|
+
* compare within a type. A value that moves while a pass is paging (a
|
|
208
|
+
* contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
|
|
209
|
+
* at all in that pass; broadcast dispatch tolerates both (the per-recipient
|
|
210
|
+
* dedupe key stops a second send, and a later pass picks up a skipped one).
|
|
211
|
+
* Index `{ <field>: -1, <idField>: 1 }` on a large collection.
|
|
212
|
+
*/
|
|
213
|
+
async querySorted(filter, limitIn, cursor, sort) {
|
|
214
|
+
const base = this.translateFilterFn(filter);
|
|
215
|
+
const limit = Math.min(limitIn, this.batchSize);
|
|
216
|
+
const dir = sort.direction === "desc" ? -1 : 1;
|
|
217
|
+
let query = base;
|
|
218
|
+
if (cursor) {
|
|
219
|
+
const pos = decodeSortCursor(cursor);
|
|
220
|
+
if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
|
|
221
|
+
query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
|
|
222
|
+
}
|
|
223
|
+
const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
|
|
224
|
+
const hasMore = docs.length > limit;
|
|
225
|
+
const slice = hasMore ? docs.slice(0, limit) : docs;
|
|
226
|
+
const last = slice[slice.length - 1];
|
|
227
|
+
return {
|
|
228
|
+
contacts: slice.map((d) => this.toContactFn(d)),
|
|
229
|
+
nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
afterPosition(field, dir, value, id) {
|
|
233
|
+
const idF = this.idField;
|
|
234
|
+
const isNull = value === null || value === void 0;
|
|
235
|
+
if (dir === -1) {
|
|
236
|
+
if (isNull) return { [field]: null, [idF]: { $gt: id } };
|
|
237
|
+
return {
|
|
238
|
+
$or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
|
|
242
|
+
return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
|
|
243
|
+
}
|
|
159
244
|
addTags;
|
|
160
245
|
removeTags;
|
|
161
246
|
// -------------------------------------------------------------------------
|
|
@@ -505,6 +590,35 @@ var sendOneOffInputSchema = z.object({
|
|
|
505
590
|
providerOverride: z.string().optional(),
|
|
506
591
|
dedupeKey: z.string().min(1).max(512)
|
|
507
592
|
});
|
|
593
|
+
var subscriptionStatusEnum = z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
|
|
594
|
+
var fieldNameSchema = (strict) => (strict ? z.string().min(1) : z.string()).max(256).regex(/^(?!\$)[^\0]*$/, "must not start with $");
|
|
595
|
+
var segmentValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
596
|
+
function segmentFilterSchema(strict) {
|
|
597
|
+
const str = strict ? z.string().min(1).max(256) : z.string().max(256);
|
|
598
|
+
const days = z.number().int().positive().max(36500).optional();
|
|
599
|
+
const self = z.lazy(
|
|
600
|
+
() => z.discriminatedUnion("kind", [
|
|
601
|
+
z.object({ kind: z.literal("fieldEquals"), field: fieldNameSchema(strict), value: segmentValueSchema }),
|
|
602
|
+
z.object({ kind: z.literal("fieldIn"), field: fieldNameSchema(strict), values: z.array(segmentValueSchema).max(1e3) }),
|
|
603
|
+
z.object({ kind: z.literal("fieldExists"), field: fieldNameSchema(strict) }),
|
|
604
|
+
z.object({ kind: z.literal("hasTag"), tag: str }),
|
|
605
|
+
z.object({ kind: z.literal("notHasTag"), tag: str }),
|
|
606
|
+
z.object({ kind: z.literal("subscriptionStatus"), equals: subscriptionStatusEnum }),
|
|
607
|
+
z.object({ kind: z.literal("firedEvent"), eventName: str, withinDays: days }),
|
|
608
|
+
z.object({ kind: z.literal("notFiredEvent"), eventName: str, withinDays: days }),
|
|
609
|
+
z.object({ kind: z.literal("subscribedAfter"), date: z.coerce.date() }),
|
|
610
|
+
z.object({ kind: z.literal("subscribedBefore"), date: z.coerce.date() }),
|
|
611
|
+
z.object({ kind: z.literal("opened"), templateSlug: slugSchema.optional(), withinDays: days }),
|
|
612
|
+
z.object({ kind: z.literal("notOpened"), templateSlug: slugSchema.optional(), withinDays: days }),
|
|
613
|
+
z.object({ kind: z.literal("any"), filters: strict ? z.array(self).min(1).max(50) : z.array(self).max(50) }),
|
|
614
|
+
z.object({ kind: z.literal("not"), filter: self })
|
|
615
|
+
])
|
|
616
|
+
);
|
|
617
|
+
return self;
|
|
618
|
+
}
|
|
619
|
+
function segmentDefinitionSchema(strict) {
|
|
620
|
+
return z.object({ filters: z.array(segmentFilterSchema(strict)).max(50) });
|
|
621
|
+
}
|
|
508
622
|
var flowStepSchema = z.lazy(
|
|
509
623
|
() => z.discriminatedUnion("type", [
|
|
510
624
|
z.object({
|
|
@@ -673,6 +787,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
|
|
|
673
787
|
windowMinutes: 60,
|
|
674
788
|
minSendsBeforeEval: 100
|
|
675
789
|
};
|
|
790
|
+
var BROADCAST_STOP_RULE_DEFAULTS = {
|
|
791
|
+
enabled: true,
|
|
792
|
+
hardBounceRatePct: 2,
|
|
793
|
+
complaintRatePct: 0.1,
|
|
794
|
+
unsubscribeRatePct: 1,
|
|
795
|
+
minSample: 100
|
|
796
|
+
};
|
|
676
797
|
function resolveConfig(c) {
|
|
677
798
|
return {
|
|
678
799
|
...c,
|
|
@@ -700,7 +821,8 @@ function resolveConfig(c) {
|
|
|
700
821
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
701
822
|
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
702
823
|
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
703
|
-
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
824
|
+
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
|
|
825
|
+
broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
|
|
704
826
|
};
|
|
705
827
|
}
|
|
706
828
|
|
|
@@ -918,11 +1040,13 @@ function resolveProvider(providers, name) {
|
|
|
918
1040
|
function registeredProviderNames(providers) {
|
|
919
1041
|
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
920
1042
|
}
|
|
1043
|
+
var SEND_ID_RE = /^[a-f0-9]{24}$/i;
|
|
921
1044
|
function signUnsubscribeToken(payload, secret) {
|
|
922
1045
|
const body = JSON.stringify({
|
|
923
1046
|
e: payload.email.toLowerCase(),
|
|
924
1047
|
s: payload.scope,
|
|
925
|
-
x: payload.expiresAt.getTime()
|
|
1048
|
+
x: payload.expiresAt.getTime(),
|
|
1049
|
+
...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
|
|
926
1050
|
});
|
|
927
1051
|
const bodyB64 = b64url(Buffer.from(body, "utf8"));
|
|
928
1052
|
const hmac = crypto2.createHmac("sha256", secret).update(bodyB64).digest();
|
|
@@ -953,7 +1077,8 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
|
|
|
953
1077
|
return {
|
|
954
1078
|
email: body.e,
|
|
955
1079
|
scope: body.s,
|
|
956
|
-
expiresAt: new Date(body.x)
|
|
1080
|
+
expiresAt: new Date(body.x),
|
|
1081
|
+
...typeof body.i === "string" && SEND_ID_RE.test(body.i) ? { sendId: body.i } : {}
|
|
957
1082
|
};
|
|
958
1083
|
}
|
|
959
1084
|
var TRACKING_SCOPES = ["open", "click"];
|
|
@@ -1574,11 +1699,11 @@ var ZERO_COUNTERS = {
|
|
|
1574
1699
|
complained: 0,
|
|
1575
1700
|
failedToSend: 0
|
|
1576
1701
|
};
|
|
1577
|
-
var
|
|
1578
|
-
bounceRate:
|
|
1579
|
-
hardBounceRate:
|
|
1580
|
-
complaintRate:
|
|
1581
|
-
failureRate:
|
|
1702
|
+
var EMPTY_RATES = {
|
|
1703
|
+
bounceRate: null,
|
|
1704
|
+
hardBounceRate: null,
|
|
1705
|
+
complaintRate: null,
|
|
1706
|
+
failureRate: null
|
|
1582
1707
|
};
|
|
1583
1708
|
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1584
1709
|
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
@@ -1608,7 +1733,7 @@ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowM
|
|
|
1608
1733
|
trippedAt: null,
|
|
1609
1734
|
trippedReason: null,
|
|
1610
1735
|
manuallyResumedAt: null,
|
|
1611
|
-
rates: { ...
|
|
1736
|
+
rates: { ...EMPTY_RATES }
|
|
1612
1737
|
},
|
|
1613
1738
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1614
1739
|
},
|
|
@@ -1635,7 +1760,7 @@ async function evaluateHealth(ctx) {
|
|
|
1635
1760
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1636
1761
|
windowDurationMs: windowMs,
|
|
1637
1762
|
counters: { ...ZERO_COUNTERS },
|
|
1638
|
-
rates: { ...
|
|
1763
|
+
rates: { ...EMPTY_RATES },
|
|
1639
1764
|
status: "healthy",
|
|
1640
1765
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1641
1766
|
}
|
|
@@ -1643,13 +1768,21 @@ async function evaluateHealth(ctx) {
|
|
|
1643
1768
|
);
|
|
1644
1769
|
continue;
|
|
1645
1770
|
}
|
|
1646
|
-
const c = doc.counters;
|
|
1647
|
-
const
|
|
1771
|
+
const c = { ...ZERO_COUNTERS, ...doc.counters };
|
|
1772
|
+
const perSent = (n) => c.sent > 0 ? n / c.sent : null;
|
|
1773
|
+
const attempts = c.sent + c.failedToSend;
|
|
1648
1774
|
const rates = {
|
|
1649
|
-
bounceRate: c.bounced
|
|
1650
|
-
hardBounceRate: c.hardBounced
|
|
1651
|
-
complaintRate: c.complained
|
|
1652
|
-
|
|
1775
|
+
bounceRate: perSent(c.bounced),
|
|
1776
|
+
hardBounceRate: perSent(c.hardBounced),
|
|
1777
|
+
complaintRate: perSent(c.complained),
|
|
1778
|
+
// Failed attempts over all attempts, so it stays within 0–100%.
|
|
1779
|
+
failureRate: attempts > 0 ? c.failedToSend / attempts : null
|
|
1780
|
+
};
|
|
1781
|
+
const pct2 = {
|
|
1782
|
+
bounce: (rates.bounceRate ?? 0) * 100,
|
|
1783
|
+
hardBounce: (rates.hardBounceRate ?? 0) * 100,
|
|
1784
|
+
complaint: (rates.complaintRate ?? 0) * 100,
|
|
1785
|
+
failure: (rates.failureRate ?? 0) * 100
|
|
1653
1786
|
};
|
|
1654
1787
|
await ctx.collections.health.updateOne(
|
|
1655
1788
|
{ _id: doc._id },
|
|
@@ -1659,12 +1792,12 @@ async function evaluateHealth(ctx) {
|
|
|
1659
1792
|
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1660
1793
|
if (doc.status === "tripped") continue;
|
|
1661
1794
|
let trippedReason = null;
|
|
1662
|
-
if (
|
|
1663
|
-
trippedReason = `hard bounce rate ${
|
|
1664
|
-
} else if (
|
|
1665
|
-
trippedReason = `complaint rate ${
|
|
1666
|
-
} else if (
|
|
1667
|
-
trippedReason = `combined bounce rate ${
|
|
1795
|
+
if (pct2.hardBounce >= cb.hardBounceRatePctTrip) {
|
|
1796
|
+
trippedReason = `hard bounce rate ${pct2.hardBounce.toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1797
|
+
} else if (pct2.complaint >= cb.complaintRatePctTrip) {
|
|
1798
|
+
trippedReason = `complaint rate ${pct2.complaint.toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1799
|
+
} else if (pct2.bounce >= cb.combinedBounceRatePctTrip) {
|
|
1800
|
+
trippedReason = `combined bounce rate ${pct2.bounce.toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1668
1801
|
}
|
|
1669
1802
|
if (trippedReason) {
|
|
1670
1803
|
const result = await ctx.collections.health.updateOne(
|
|
@@ -1699,7 +1832,7 @@ async function evaluateHealth(ctx) {
|
|
|
1699
1832
|
}
|
|
1700
1833
|
continue;
|
|
1701
1834
|
}
|
|
1702
|
-
if (
|
|
1835
|
+
if (pct2.failure >= cb.failedToSendRatePctDegrade) {
|
|
1703
1836
|
if (doc.status !== "degraded") {
|
|
1704
1837
|
await ctx.collections.health.updateOne(
|
|
1705
1838
|
{ _id: doc._id },
|
|
@@ -1728,6 +1861,211 @@ function effectiveOverallStatus(docs) {
|
|
|
1728
1861
|
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1729
1862
|
return "healthy";
|
|
1730
1863
|
}
|
|
1864
|
+
function emptyBroadcastStats() {
|
|
1865
|
+
return withRates({
|
|
1866
|
+
total: 0,
|
|
1867
|
+
accepted: 0,
|
|
1868
|
+
delivered: 0,
|
|
1869
|
+
bounced: 0,
|
|
1870
|
+
hardBounced: 0,
|
|
1871
|
+
softBounced: 0,
|
|
1872
|
+
complained: 0,
|
|
1873
|
+
unsubscribed: 0,
|
|
1874
|
+
opened: 0,
|
|
1875
|
+
clicked: 0,
|
|
1876
|
+
outcomes: 0
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
|
|
1880
|
+
function withRates(c) {
|
|
1881
|
+
return {
|
|
1882
|
+
...c,
|
|
1883
|
+
rates: {
|
|
1884
|
+
deliveryRatePct: pct(c.delivered, c.accepted),
|
|
1885
|
+
bounceRatePct: pct(c.bounced, c.outcomes),
|
|
1886
|
+
hardBounceRatePct: pct(c.hardBounced, c.outcomes),
|
|
1887
|
+
complaintRatePct: pct(c.complained, c.outcomes),
|
|
1888
|
+
unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
|
|
1889
|
+
openRatePct: pct(c.opened, c.delivered),
|
|
1890
|
+
clickRatePct: pct(c.clicked, c.delivered)
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
var set = (field) => ({ $ifNull: [field, false] });
|
|
1895
|
+
var DELIVERED = {
|
|
1896
|
+
$or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
|
|
1897
|
+
};
|
|
1898
|
+
var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
|
|
1899
|
+
var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
|
|
1900
|
+
async function aggregateBroadcastStats(collections, broadcastId) {
|
|
1901
|
+
const rows = await collections.sends.aggregate([
|
|
1902
|
+
{ $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
|
|
1903
|
+
{
|
|
1904
|
+
$group: {
|
|
1905
|
+
_id: "$broadcastId",
|
|
1906
|
+
total: { $sum: 1 },
|
|
1907
|
+
accepted: count(set("$sentAt")),
|
|
1908
|
+
delivered: count(DELIVERED),
|
|
1909
|
+
bounced: count(BOUNCED),
|
|
1910
|
+
hardBounced: count({ $eq: ["$bounceType", "hard"] }),
|
|
1911
|
+
softBounced: count({ $eq: ["$bounceType", "soft"] }),
|
|
1912
|
+
complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
|
|
1913
|
+
unsubscribed: count(set("$unsubscribedAt")),
|
|
1914
|
+
opened: count(set("$openedAt")),
|
|
1915
|
+
clicked: count(set("$firstClickAt")),
|
|
1916
|
+
outcomes: count({ $or: [DELIVERED, BOUNCED] })
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
]).toArray();
|
|
1920
|
+
const out = /* @__PURE__ */ new Map();
|
|
1921
|
+
for (const { _id, ...c } of rows) {
|
|
1922
|
+
if (_id) out.set(String(_id), withRates(c));
|
|
1923
|
+
}
|
|
1924
|
+
return out;
|
|
1925
|
+
}
|
|
1926
|
+
async function broadcastStatsFor(collections, broadcastId) {
|
|
1927
|
+
return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
|
|
1928
|
+
}
|
|
1929
|
+
function effectiveStopRules(config, b) {
|
|
1930
|
+
return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
|
|
1931
|
+
}
|
|
1932
|
+
var RULE_COUNTS = [
|
|
1933
|
+
["complaintRatePct", "complained"],
|
|
1934
|
+
["hardBounceRatePct", "hardBounced"],
|
|
1935
|
+
["unsubscribeRatePct", "unsubscribed"]
|
|
1936
|
+
];
|
|
1937
|
+
function evaluateStopRules(stats, rules) {
|
|
1938
|
+
const sample = stats.outcomes;
|
|
1939
|
+
const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
|
|
1940
|
+
const breaches = [];
|
|
1941
|
+
if (evaluated) {
|
|
1942
|
+
for (const [rule, key] of RULE_COUNTS) {
|
|
1943
|
+
const n = stats[key];
|
|
1944
|
+
const exactPct = n / sample * 100;
|
|
1945
|
+
if (exactPct > rules[rule]) {
|
|
1946
|
+
breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return { rules, sample, evaluated, breaches };
|
|
1951
|
+
}
|
|
1952
|
+
var RULE_LABEL = {
|
|
1953
|
+
complaintRatePct: "complaint rate",
|
|
1954
|
+
hardBounceRatePct: "hard bounce rate",
|
|
1955
|
+
unsubscribeRatePct: "unsubscribe rate"
|
|
1956
|
+
};
|
|
1957
|
+
var PAUSE_PRECEDENCE = {
|
|
1958
|
+
manual: 1,
|
|
1959
|
+
cap_reached: 1,
|
|
1960
|
+
stop_rule: 2,
|
|
1961
|
+
circuit_breaker: 2
|
|
1962
|
+
};
|
|
1963
|
+
async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
|
|
1964
|
+
const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
|
|
1965
|
+
(k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
|
|
1966
|
+
);
|
|
1967
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
1968
|
+
{
|
|
1969
|
+
_id: broadcastId,
|
|
1970
|
+
$or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
|
|
1971
|
+
},
|
|
1972
|
+
{ $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
|
|
1973
|
+
{ returnDocument: "after" }
|
|
1974
|
+
);
|
|
1975
|
+
if (!b) return { paused: false, heldSends: 0 };
|
|
1976
|
+
const held = await holdQueuedSends(ctx, broadcastId, reason.code);
|
|
1977
|
+
if (ctx.audit) {
|
|
1978
|
+
await ctx.audit({
|
|
1979
|
+
actor,
|
|
1980
|
+
action: "broadcast.pause",
|
|
1981
|
+
resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
|
|
1982
|
+
diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
|
|
1983
|
+
}).catch(() => {
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
if (ctx.config.onBroadcastPaused) {
|
|
1987
|
+
try {
|
|
1988
|
+
await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
|
|
1989
|
+
} catch {
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return { paused: true, heldSends: held };
|
|
1993
|
+
}
|
|
1994
|
+
async function holdQueuedSends(ctx, broadcastId, code) {
|
|
1995
|
+
const res = await ctx.collections.sends.updateMany(
|
|
1996
|
+
{ broadcastId, status: "queued" },
|
|
1997
|
+
{ $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1998
|
+
);
|
|
1999
|
+
return res.modifiedCount;
|
|
2000
|
+
}
|
|
2001
|
+
async function releaseHeldSends(ctx, broadcastId) {
|
|
2002
|
+
const held = await ctx.collections.sends.find({ broadcastId, status: "held" }, { projection: { _id: 1, notBefore: 1 } }).toArray();
|
|
2003
|
+
if (held.length === 0) return 0;
|
|
2004
|
+
await ctx.collections.sends.updateMany(
|
|
2005
|
+
{ _id: { $in: held.map((s) => s._id) }, status: "held" },
|
|
2006
|
+
{ $set: { status: "queued", errorMessage: null, updatedAt: /* @__PURE__ */ new Date() } }
|
|
2007
|
+
);
|
|
2008
|
+
const now = Date.now();
|
|
2009
|
+
await Promise.all(
|
|
2010
|
+
held.map((s) => {
|
|
2011
|
+
const delay = s.notBefore ? Math.max(0, new Date(s.notBefore).getTime() - now) : 0;
|
|
2012
|
+
return ctx.queues.send.add(
|
|
2013
|
+
"send",
|
|
2014
|
+
{ sendId: String(s._id) },
|
|
2015
|
+
{
|
|
2016
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
2017
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
2018
|
+
...delay > 0 ? { delay } : {}
|
|
2019
|
+
}
|
|
2020
|
+
);
|
|
2021
|
+
})
|
|
2022
|
+
);
|
|
2023
|
+
return held.length;
|
|
2024
|
+
}
|
|
2025
|
+
async function evaluateBroadcastStopRules(ctx, broadcastId) {
|
|
2026
|
+
const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
|
|
2027
|
+
if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
|
|
2028
|
+
if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
|
|
2029
|
+
const stats = await broadcastStatsFor(ctx.collections, broadcastId);
|
|
2030
|
+
const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
|
|
2031
|
+
if (evaluation.breaches.length === 0) return evaluation;
|
|
2032
|
+
const now = /* @__PURE__ */ new Date();
|
|
2033
|
+
if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
|
|
2034
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2035
|
+
{ _id: broadcastId, stopRuleBreach: { $in: [null] } },
|
|
2036
|
+
{ $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
|
|
2037
|
+
);
|
|
2038
|
+
return evaluation;
|
|
2039
|
+
}
|
|
2040
|
+
const first = evaluation.breaches[0];
|
|
2041
|
+
await pauseBroadcast(ctx, broadcastId, {
|
|
2042
|
+
code: "stop_rule",
|
|
2043
|
+
message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
|
|
2044
|
+
at: now,
|
|
2045
|
+
details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
|
|
2046
|
+
});
|
|
2047
|
+
return evaluation;
|
|
2048
|
+
}
|
|
2049
|
+
var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
2050
|
+
async function evaluateActiveBroadcastStopRules(ctx) {
|
|
2051
|
+
const live = await ctx.collections.broadcasts.find(
|
|
2052
|
+
{ status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
|
|
2053
|
+
{ projection: { _id: 1 } }
|
|
2054
|
+
).limit(200).toArray();
|
|
2055
|
+
for (const { _id } of live) {
|
|
2056
|
+
await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
|
|
2057
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
async function attributeUnsubscribeToSend(ctx, sendId, email) {
|
|
2062
|
+
if (!ObjectId.isValid(sendId)) return;
|
|
2063
|
+
const _id = new ObjectId(sendId);
|
|
2064
|
+
const send = await ctx.collections.sends.findOne({ _id }, { projection: { emailAtSend: 1, broadcastId: 1 } });
|
|
2065
|
+
if (!send || send.emailAtSend.toLowerCase() !== email.toLowerCase()) return;
|
|
2066
|
+
await ctx.collections.sends.updateOne({ _id, unsubscribedAt: null }, { $set: { unsubscribedAt: /* @__PURE__ */ new Date() } });
|
|
2067
|
+
if (send.broadcastId) await evaluateBroadcastStopRules(ctx, send.broadcastId);
|
|
2068
|
+
}
|
|
1731
2069
|
|
|
1732
2070
|
// src/server/runner/webhook.ts
|
|
1733
2071
|
function dimsFromSend(send) {
|
|
@@ -1882,6 +2220,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
1882
2220
|
);
|
|
1883
2221
|
break;
|
|
1884
2222
|
}
|
|
2223
|
+
if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
|
|
2224
|
+
await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
|
|
2225
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
1885
2228
|
}
|
|
1886
2229
|
async function suppressOnce(ctx, email, reason, scope) {
|
|
1887
2230
|
const normalized = email.toLowerCase();
|
|
@@ -2293,11 +2636,30 @@ async function isSuppressed(collections, email, kind) {
|
|
|
2293
2636
|
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
2294
2637
|
const hashed = await collections.suppressions.findOne({
|
|
2295
2638
|
emailHash: sha256Hex(normalized),
|
|
2296
|
-
scope: { $in: allowed }
|
|
2639
|
+
scope: { $in: allowed },
|
|
2640
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
2297
2641
|
});
|
|
2298
2642
|
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
2299
2643
|
return { suppressed: false };
|
|
2300
2644
|
}
|
|
2645
|
+
async function suppressedEmails(collections, emails, kind) {
|
|
2646
|
+
const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
|
|
2647
|
+
if (normalized.length === 0) return /* @__PURE__ */ new Set();
|
|
2648
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
2649
|
+
const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
|
|
2650
|
+
const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
|
|
2651
|
+
const [plain, hashed] = await Promise.all([
|
|
2652
|
+
collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
|
|
2653
|
+
collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
|
|
2654
|
+
]);
|
|
2655
|
+
const out = /* @__PURE__ */ new Set();
|
|
2656
|
+
for (const e of plain) if (typeof e === "string") out.add(e);
|
|
2657
|
+
for (const h of hashed) {
|
|
2658
|
+
const e = byHash.get(String(h));
|
|
2659
|
+
if (e) out.add(e);
|
|
2660
|
+
}
|
|
2661
|
+
return out;
|
|
2662
|
+
}
|
|
2301
2663
|
|
|
2302
2664
|
// src/server/runner/send.ts
|
|
2303
2665
|
async function handleSend(run, step, contact, flow, ctx) {
|
|
@@ -2374,6 +2736,27 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2374
2736
|
await markFailed(send._id, "template_missing", ctx);
|
|
2375
2737
|
return;
|
|
2376
2738
|
}
|
|
2739
|
+
if (send.broadcastId) {
|
|
2740
|
+
const broadcast = await ctx.collections.broadcasts.findOne(
|
|
2741
|
+
{ _id: send.broadcastId },
|
|
2742
|
+
{ projection: { status: 1, pauseReason: 1 } }
|
|
2743
|
+
);
|
|
2744
|
+
const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
|
|
2745
|
+
if (holding || broadcast?.status === "cancelled") {
|
|
2746
|
+
const held = holding;
|
|
2747
|
+
await ctx.collections.sends.updateOne(
|
|
2748
|
+
{ _id: send._id },
|
|
2749
|
+
{
|
|
2750
|
+
$set: {
|
|
2751
|
+
status: held ? "held" : "cancelled",
|
|
2752
|
+
errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
|
|
2753
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2377
2760
|
const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
|
|
2378
2761
|
if (supp.suppressed) {
|
|
2379
2762
|
await ctx.collections.sends.updateOne(
|
|
@@ -2384,6 +2767,19 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2384
2767
|
}
|
|
2385
2768
|
if (send.kind === "marketing") {
|
|
2386
2769
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
2770
|
+
if (bucket?.status === "tripped" && send.broadcastId) {
|
|
2771
|
+
await ctx.collections.sends.updateOne(
|
|
2772
|
+
{ _id: send._id },
|
|
2773
|
+
{ $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2774
|
+
);
|
|
2775
|
+
await pauseBroadcast(ctx, send.broadcastId, {
|
|
2776
|
+
code: "circuit_breaker",
|
|
2777
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
2778
|
+
at: /* @__PURE__ */ new Date(),
|
|
2779
|
+
details: { bucket: bucket._id }
|
|
2780
|
+
});
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2387
2783
|
if (bucket?.status === "tripped") {
|
|
2388
2784
|
await ctx.collections.sends.updateOne(
|
|
2389
2785
|
{ _id: send._id },
|
|
@@ -2398,6 +2794,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2398
2794
|
await markFailed(send._id, "contact_missing", ctx);
|
|
2399
2795
|
return;
|
|
2400
2796
|
}
|
|
2797
|
+
if (!send.emailAtSend?.trim() || !contact.email?.trim()) {
|
|
2798
|
+
await markFailed(send._id, "no_recipient: the contact has no email address", ctx);
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2401
2801
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
2402
2802
|
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
2403
2803
|
await ctx.collections.sends.updateOne(
|
|
@@ -2416,7 +2816,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2416
2816
|
eventName: run?.triggerEvent?.name,
|
|
2417
2817
|
eventProperties: run?.triggerEvent?.properties
|
|
2418
2818
|
});
|
|
2419
|
-
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
2819
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
|
|
2420
2820
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
2421
2821
|
} catch (err) {
|
|
2422
2822
|
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
@@ -2507,11 +2907,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
2507
2907
|
}
|
|
2508
2908
|
return ctx.config.defaultProvider;
|
|
2509
2909
|
}
|
|
2510
|
-
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
2910
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
|
|
2511
2911
|
const scope = "marketing";
|
|
2512
2912
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
2513
2913
|
const token = signUnsubscribeToken(
|
|
2514
|
-
{ email: contact.email, scope, expiresAt },
|
|
2914
|
+
{ email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
|
|
2515
2915
|
ctx.config.unsubscribeSecret
|
|
2516
2916
|
);
|
|
2517
2917
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
@@ -2594,6 +2994,10 @@ async function processOneRunStep(runId, ctx) {
|
|
|
2594
2994
|
await exitFlowRun(run, "contact_missing", ctx);
|
|
2595
2995
|
return;
|
|
2596
2996
|
}
|
|
2997
|
+
if (!contact.email?.trim()) {
|
|
2998
|
+
await exitFlowRun(run, "contact_no_email", ctx);
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
2597
3001
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
|
|
2598
3002
|
if (sub && sub.status !== "subscribed") {
|
|
2599
3003
|
await exitFlowRun(run, sub.status, ctx);
|
|
@@ -2887,6 +3291,184 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
2887
3291
|
}
|
|
2888
3292
|
}
|
|
2889
3293
|
}
|
|
3294
|
+
|
|
3295
|
+
// src/server/runner/segment.ts
|
|
3296
|
+
function planSegment(seg) {
|
|
3297
|
+
const hostFilter = {};
|
|
3298
|
+
const postFilters = [];
|
|
3299
|
+
const hostFields = /* @__PURE__ */ new Set();
|
|
3300
|
+
const takeField = (field) => {
|
|
3301
|
+
if (hostFields.has(field)) return false;
|
|
3302
|
+
hostFields.add(field);
|
|
3303
|
+
return true;
|
|
3304
|
+
};
|
|
3305
|
+
for (const f of seg.filters ?? []) {
|
|
3306
|
+
switch (f.kind) {
|
|
3307
|
+
case "hasTag":
|
|
3308
|
+
if (hostFilter.hasTag === void 0) {
|
|
3309
|
+
hostFilter.hasTag = f.tag;
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
break;
|
|
3313
|
+
case "fieldEquals":
|
|
3314
|
+
if (!hostFilter.fieldEquals && takeField(f.field)) {
|
|
3315
|
+
hostFilter.fieldEquals = { field: f.field, value: f.value };
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
break;
|
|
3319
|
+
case "fieldIn":
|
|
3320
|
+
if (!hostFilter.fieldIn && takeField(f.field)) {
|
|
3321
|
+
hostFilter.fieldIn = { field: f.field, values: f.values };
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
break;
|
|
3325
|
+
case "fieldExists":
|
|
3326
|
+
if (!hostFilter.fieldExists && takeField(f.field)) {
|
|
3327
|
+
hostFilter.fieldExists = f.field;
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
break;
|
|
3331
|
+
}
|
|
3332
|
+
postFilters.push(f);
|
|
3333
|
+
}
|
|
3334
|
+
return { hostFilter, postFilters };
|
|
3335
|
+
}
|
|
3336
|
+
async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
|
|
3337
|
+
if (filters.length === 0 || contacts.length === 0) return contacts;
|
|
3338
|
+
const externalIds = contacts.map((c) => c.externalId);
|
|
3339
|
+
const leaves = /* @__PURE__ */ new Map();
|
|
3340
|
+
collectLookups(filters, leaves);
|
|
3341
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3342
|
+
await Promise.all(
|
|
3343
|
+
[...leaves].map(async ([key, f]) => {
|
|
3344
|
+
cache.set(key, await lookup(f, externalIds, ctx, now));
|
|
3345
|
+
})
|
|
3346
|
+
);
|
|
3347
|
+
return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
|
|
3348
|
+
}
|
|
3349
|
+
function lookupKey(f) {
|
|
3350
|
+
switch (f.kind) {
|
|
3351
|
+
case "subscriptionStatus":
|
|
3352
|
+
return `sub:${f.equals}`;
|
|
3353
|
+
case "firedEvent":
|
|
3354
|
+
case "notFiredEvent":
|
|
3355
|
+
return `evt:${f.withinDays ?? ""}:${f.eventName}`;
|
|
3356
|
+
case "opened":
|
|
3357
|
+
case "notOpened":
|
|
3358
|
+
return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
|
|
3359
|
+
case "subscribedAfter":
|
|
3360
|
+
return `subAfter:${toDate(f.date).getTime()}`;
|
|
3361
|
+
case "subscribedBefore":
|
|
3362
|
+
return `subBefore:${toDate(f.date).getTime()}`;
|
|
3363
|
+
default:
|
|
3364
|
+
return null;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
function collectLookups(filters, out) {
|
|
3368
|
+
for (const f of filters) {
|
|
3369
|
+
if (f.kind === "any") collectLookups(f.filters, out);
|
|
3370
|
+
else if (f.kind === "not") collectLookups([f.filter], out);
|
|
3371
|
+
else {
|
|
3372
|
+
const key = lookupKey(f);
|
|
3373
|
+
if (key && !out.has(key)) out.set(key, f);
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
async function lookup(f, externalIds, ctx, now) {
|
|
3378
|
+
const c = ctx.collections;
|
|
3379
|
+
const inPage = { $in: externalIds };
|
|
3380
|
+
const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
|
|
3381
|
+
let ids;
|
|
3382
|
+
switch (f.kind) {
|
|
3383
|
+
case "subscriptionStatus":
|
|
3384
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
|
|
3385
|
+
break;
|
|
3386
|
+
case "firedEvent":
|
|
3387
|
+
case "notFiredEvent": {
|
|
3388
|
+
const cutoff = since(f.withinDays);
|
|
3389
|
+
ids = await c.events.distinct("externalId", {
|
|
3390
|
+
externalId: inPage,
|
|
3391
|
+
name: f.eventName,
|
|
3392
|
+
...cutoff ? { occurredAt: { $gt: cutoff } } : {}
|
|
3393
|
+
});
|
|
3394
|
+
break;
|
|
3395
|
+
}
|
|
3396
|
+
case "opened":
|
|
3397
|
+
case "notOpened": {
|
|
3398
|
+
const cutoff = since(f.withinDays);
|
|
3399
|
+
ids = await c.sends.distinct("externalId", {
|
|
3400
|
+
externalId: inPage,
|
|
3401
|
+
openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
|
|
3402
|
+
...f.templateSlug ? { templateSlug: f.templateSlug } : {}
|
|
3403
|
+
});
|
|
3404
|
+
break;
|
|
3405
|
+
}
|
|
3406
|
+
case "subscribedAfter":
|
|
3407
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
|
|
3408
|
+
break;
|
|
3409
|
+
case "subscribedBefore":
|
|
3410
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
|
|
3411
|
+
break;
|
|
3412
|
+
default:
|
|
3413
|
+
ids = [];
|
|
3414
|
+
}
|
|
3415
|
+
return new Set(ids.map(String));
|
|
3416
|
+
}
|
|
3417
|
+
function matches(c, f, cache) {
|
|
3418
|
+
switch (f.kind) {
|
|
3419
|
+
case "subscriptionStatus":
|
|
3420
|
+
case "firedEvent":
|
|
3421
|
+
case "opened":
|
|
3422
|
+
case "subscribedAfter":
|
|
3423
|
+
case "subscribedBefore":
|
|
3424
|
+
return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
|
|
3425
|
+
case "notFiredEvent":
|
|
3426
|
+
case "notOpened":
|
|
3427
|
+
return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
|
|
3428
|
+
case "hasTag":
|
|
3429
|
+
return c.tags.includes(f.tag);
|
|
3430
|
+
case "notHasTag":
|
|
3431
|
+
return !c.tags.includes(f.tag);
|
|
3432
|
+
case "fieldEquals":
|
|
3433
|
+
return valuesEqual(fieldValue(c, f.field), f.value);
|
|
3434
|
+
case "fieldIn": {
|
|
3435
|
+
const v = fieldValue(c, f.field);
|
|
3436
|
+
return f.values.some((x) => valuesEqual(v, x));
|
|
3437
|
+
}
|
|
3438
|
+
case "fieldExists":
|
|
3439
|
+
return fieldValue(c, f.field) !== void 0;
|
|
3440
|
+
case "any":
|
|
3441
|
+
return f.filters.some((sub) => matches(c, sub, cache));
|
|
3442
|
+
case "not":
|
|
3443
|
+
return !matches(c, f.filter, cache);
|
|
3444
|
+
default:
|
|
3445
|
+
throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
function fieldValue(c, path3) {
|
|
3449
|
+
let cur = c.fields;
|
|
3450
|
+
for (const part of path3.split(".")) {
|
|
3451
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
3452
|
+
cur = cur[part];
|
|
3453
|
+
}
|
|
3454
|
+
return cur;
|
|
3455
|
+
}
|
|
3456
|
+
function valuesEqual(a, b) {
|
|
3457
|
+
if (a instanceof Date || b instanceof Date) {
|
|
3458
|
+
const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
|
|
3459
|
+
const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
|
|
3460
|
+
return !Number.isNaN(ta) && ta === tb;
|
|
3461
|
+
}
|
|
3462
|
+
if (a && typeof a === "object" && typeof a.toHexString === "function") {
|
|
3463
|
+
return String(a) === String(b);
|
|
3464
|
+
}
|
|
3465
|
+
return a === b;
|
|
3466
|
+
}
|
|
3467
|
+
function toDate(d) {
|
|
3468
|
+
return d instanceof Date ? d : new Date(d);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// src/server/runner/broadcasts.ts
|
|
2890
3472
|
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
2891
3473
|
async function processScheduledBroadcasts(ctx) {
|
|
2892
3474
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -2894,7 +3476,7 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2894
3476
|
for (const b of due) {
|
|
2895
3477
|
const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
2896
3478
|
{ _id: b._id, status: "scheduled" },
|
|
2897
|
-
{ $set: { status: "sending", startedAt: now, updatedAt: now } },
|
|
3479
|
+
{ $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
|
|
2898
3480
|
{ returnDocument: "after" }
|
|
2899
3481
|
);
|
|
2900
3482
|
if (!claimed) continue;
|
|
@@ -2902,8 +3484,14 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2902
3484
|
}
|
|
2903
3485
|
}
|
|
2904
3486
|
async function startBroadcastDispatch(broadcast, ctx) {
|
|
3487
|
+
const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
3488
|
+
{ _id: broadcast._id },
|
|
3489
|
+
{ $inc: { dispatchGeneration: 1 } },
|
|
3490
|
+
{ returnDocument: "after" }
|
|
3491
|
+
);
|
|
3492
|
+
const current = bumped ?? broadcast;
|
|
2905
3493
|
if (ctx.config.queue.driver === "noop") {
|
|
2906
|
-
await runBroadcastDispatch(
|
|
3494
|
+
await runBroadcastDispatch(current, ctx);
|
|
2907
3495
|
return;
|
|
2908
3496
|
}
|
|
2909
3497
|
await ctx.queues.advance.add(
|
|
@@ -2912,7 +3500,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
|
|
|
2912
3500
|
{
|
|
2913
3501
|
attempts: 3,
|
|
2914
3502
|
backoff: { type: "exponential", delay: 6e4 },
|
|
2915
|
-
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
3503
|
+
jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
|
|
2916
3504
|
}
|
|
2917
3505
|
);
|
|
2918
3506
|
}
|
|
@@ -2925,10 +3513,11 @@ async function resumeStalledBroadcasts(ctx) {
|
|
|
2925
3513
|
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
2926
3514
|
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
2927
3515
|
for (const b of stalled) {
|
|
2928
|
-
await ctx.collections.broadcasts.updateOne(
|
|
2929
|
-
{ _id: b._id },
|
|
2930
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
3516
|
+
const reset = await ctx.collections.broadcasts.updateOne(
|
|
3517
|
+
{ _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
3518
|
+
{ $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
|
|
2931
3519
|
);
|
|
3520
|
+
if (reset.modifiedCount === 0) continue;
|
|
2932
3521
|
await startBroadcastDispatch(b, ctx);
|
|
2933
3522
|
}
|
|
2934
3523
|
}
|
|
@@ -2938,166 +3527,196 @@ async function runBroadcastDispatch(broadcast, ctx) {
|
|
|
2938
3527
|
} catch (err) {
|
|
2939
3528
|
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
2940
3529
|
await ctx.collections.broadcasts.updateOne(
|
|
2941
|
-
{ _id: broadcast._id },
|
|
2942
|
-
{
|
|
3530
|
+
{ _id: broadcast._id, status: "sending" },
|
|
3531
|
+
{
|
|
3532
|
+
$set: {
|
|
3533
|
+
status: "failed",
|
|
3534
|
+
failureReason: `dispatch error: ${String(err?.message ?? err)}`,
|
|
3535
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
3536
|
+
dispatchLeaseId: null
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
2943
3539
|
);
|
|
2944
3540
|
}
|
|
2945
3541
|
}
|
|
3542
|
+
function broadcastDedupeKey(broadcastId, externalId) {
|
|
3543
|
+
return `broadcast:${broadcastId}:${externalId}`;
|
|
3544
|
+
}
|
|
3545
|
+
var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3546
|
+
function isSendableEmail(email) {
|
|
3547
|
+
return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
|
|
3548
|
+
}
|
|
3549
|
+
async function* eligibleRecipientPages(broadcast, kind, ctx) {
|
|
3550
|
+
const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
|
|
3551
|
+
const sort = broadcast.order ?? void 0;
|
|
3552
|
+
if (sort && !ctx.adapter.supportsSort) {
|
|
3553
|
+
throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
|
|
3554
|
+
}
|
|
3555
|
+
let cursor;
|
|
3556
|
+
for (; ; ) {
|
|
3557
|
+
const page = await ctx.adapter.query(hostFilter, {
|
|
3558
|
+
limit: ctx.config.broadcastEnqueueBatchSize,
|
|
3559
|
+
cursor,
|
|
3560
|
+
...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
|
|
3561
|
+
});
|
|
3562
|
+
if (page.contacts.length === 0) break;
|
|
3563
|
+
const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
|
|
3564
|
+
const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
|
|
3565
|
+
yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
|
|
3566
|
+
if (!page.nextCursor) break;
|
|
3567
|
+
cursor = page.nextCursor;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
async function alreadyDispatched(ctx, broadcastId, contacts) {
|
|
3571
|
+
if (contacts.length === 0) return /* @__PURE__ */ new Set();
|
|
3572
|
+
const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
|
|
3573
|
+
const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
|
|
3574
|
+
return new Set(found.map(String));
|
|
3575
|
+
}
|
|
3576
|
+
async function countBroadcastRecipients(broadcast, kind, ctx) {
|
|
3577
|
+
const t0 = Date.now();
|
|
3578
|
+
const { hostFilter } = planSegment(broadcast.segmentDefinition);
|
|
3579
|
+
const [hostMatched, sendsSoFar] = await Promise.all([
|
|
3580
|
+
ctx.adapter.count(hostFilter),
|
|
3581
|
+
broadcast._id ? ctx.collections.sends.countDocuments({ broadcastId: broadcast._id }) : Promise.resolve(0)
|
|
3582
|
+
]);
|
|
3583
|
+
let eligible = 0;
|
|
3584
|
+
let alreadySent = 0;
|
|
3585
|
+
for await (const page of eligibleRecipientPages(broadcast, kind, ctx)) {
|
|
3586
|
+
eligible += page.length;
|
|
3587
|
+
if (broadcast._id) alreadySent += (await alreadyDispatched(ctx, broadcast._id, page)).size;
|
|
3588
|
+
}
|
|
3589
|
+
const cap = typeof broadcast.recipientCap === "number" ? broadcast.recipientCap : null;
|
|
3590
|
+
const uncapped = eligible - alreadySent;
|
|
3591
|
+
const recipientCount = cap === null ? uncapped : Math.max(0, Math.min(uncapped, cap - sendsSoFar));
|
|
3592
|
+
return {
|
|
3593
|
+
hostMatched,
|
|
3594
|
+
eligible,
|
|
3595
|
+
alreadySent,
|
|
3596
|
+
sendsSoFar,
|
|
3597
|
+
recipientCap: cap,
|
|
3598
|
+
uncappedRecipientCount: uncapped,
|
|
3599
|
+
recipientCount,
|
|
3600
|
+
computedMs: Date.now() - t0
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
2946
3603
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
2947
3604
|
const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
|
|
2948
|
-
|
|
3605
|
+
const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
|
|
3606
|
+
if (failure || !template) {
|
|
2949
3607
|
await ctx.collections.broadcasts.updateOne(
|
|
2950
|
-
{ _id: broadcast._id },
|
|
2951
|
-
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3608
|
+
{ _id: broadcast._id, status: "sending" },
|
|
3609
|
+
{ $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
|
|
2952
3610
|
);
|
|
2953
3611
|
return;
|
|
2954
3612
|
}
|
|
2955
|
-
const
|
|
2956
|
-
const
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3613
|
+
const leaseId = new ObjectId().toHexString();
|
|
3614
|
+
const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
3615
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
3616
|
+
{
|
|
3617
|
+
_id: broadcast._id,
|
|
3618
|
+
status: "sending",
|
|
3619
|
+
$or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
|
|
3620
|
+
},
|
|
3621
|
+
{ $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
|
|
3622
|
+
{ returnDocument: "after" }
|
|
3623
|
+
);
|
|
3624
|
+
if (!b) return;
|
|
3625
|
+
const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
|
|
3626
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
3627
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
3628
|
+
)).matchedCount === 1;
|
|
2960
3629
|
const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
|
|
2961
|
-
const respectTimezone =
|
|
2962
|
-
const scheduledMs =
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
3630
|
+
const respectTimezone = b.respectRecipientTimezone === true;
|
|
3631
|
+
const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
|
|
3632
|
+
const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
|
|
3633
|
+
let capReached = false;
|
|
3634
|
+
try {
|
|
3635
|
+
for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
|
|
3636
|
+
if (!await holdsLease()) return;
|
|
3637
|
+
const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
|
|
3638
|
+
if (bucket?.status === "tripped") {
|
|
3639
|
+
await pauseBroadcast(ctx, b._id, {
|
|
3640
|
+
code: "circuit_breaker",
|
|
3641
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
3642
|
+
at: /* @__PURE__ */ new Date(),
|
|
3643
|
+
details: { bucket: bucket._id }
|
|
3644
|
+
});
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
const seen = await alreadyDispatched(ctx, b._id, eligible);
|
|
3648
|
+
let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
|
|
3649
|
+
if (fresh.length === 0) continue;
|
|
3650
|
+
if (cap !== null) {
|
|
3651
|
+
const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
3652
|
+
if (room <= 0) {
|
|
3653
|
+
capReached = true;
|
|
3654
|
+
break;
|
|
3655
|
+
}
|
|
3656
|
+
if (fresh.length > room) {
|
|
3657
|
+
fresh = fresh.slice(0, room);
|
|
3658
|
+
capReached = true;
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
2972
3661
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
2973
|
-
await
|
|
2974
|
-
{ _id: broadcast._id },
|
|
2975
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2976
|
-
);
|
|
3662
|
+
if (!await holdsLease()) return;
|
|
2977
3663
|
await sleep(2e3);
|
|
2978
3664
|
}
|
|
2979
|
-
const sendDocs = await Promise.all(
|
|
2980
|
-
eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
|
|
2981
|
-
);
|
|
2982
3665
|
const inserted = [];
|
|
2983
|
-
for (const
|
|
2984
|
-
|
|
3666
|
+
for (const contact of fresh) {
|
|
3667
|
+
const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
|
|
2985
3668
|
try {
|
|
2986
3669
|
await ctx.collections.sends.insertOne(doc);
|
|
2987
3670
|
inserted.push({ sendId: doc._id, delayMs });
|
|
2988
3671
|
} catch (err) {
|
|
2989
3672
|
if (err?.code !== 11e3) throw err;
|
|
2990
3673
|
}
|
|
2991
|
-
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
}
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
case "hasTag":
|
|
3029
|
-
out.hasTag = f.tag;
|
|
3030
|
-
break;
|
|
3031
|
-
case "fieldEquals":
|
|
3032
|
-
out.fieldEquals = { field: f.field, value: f.value };
|
|
3033
|
-
break;
|
|
3034
|
-
case "fieldIn":
|
|
3035
|
-
out.fieldIn = { field: f.field, values: f.values };
|
|
3036
|
-
break;
|
|
3037
|
-
case "fieldExists":
|
|
3038
|
-
out.fieldExists = f.field;
|
|
3039
|
-
break;
|
|
3040
|
-
}
|
|
3041
|
-
}
|
|
3042
|
-
return out;
|
|
3043
|
-
}
|
|
3044
|
-
function isMailerSide(f) {
|
|
3045
|
-
return f.kind === "subscriptionStatus" || f.kind === "firedEvent" || f.kind === "notFiredEvent" || f.kind === "subscribedAfter" || f.kind === "subscribedBefore" || f.kind === "opened" || f.kind === "notOpened" || f.kind === "notHasTag" || f.kind === "any" || f.kind === "not";
|
|
3046
|
-
}
|
|
3047
|
-
async function applyPostFilters(contacts, filters, ctx) {
|
|
3048
|
-
if (filters.length === 0) return contacts;
|
|
3049
|
-
const externalIds = contacts.map((c) => c.externalId);
|
|
3050
|
-
const cache = {};
|
|
3051
|
-
for (const f of filters) {
|
|
3052
|
-
if (f.kind === "subscriptionStatus") {
|
|
3053
|
-
const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
|
|
3054
|
-
cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
|
|
3055
|
-
}
|
|
3056
|
-
if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
|
|
3057
|
-
const query = { externalId: { $in: externalIds }, name: f.eventName };
|
|
3058
|
-
if (f.withinDays) {
|
|
3059
|
-
query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
|
|
3060
|
-
}
|
|
3061
|
-
const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
|
|
3062
|
-
cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
|
|
3063
|
-
}
|
|
3064
|
-
}
|
|
3065
|
-
return contacts.filter((c) => filters.every((f) => filterMatches(c, f, cache)));
|
|
3066
|
-
}
|
|
3067
|
-
function filterMatches(c, f, cache) {
|
|
3068
|
-
switch (f.kind) {
|
|
3069
|
-
case "subscriptionStatus":
|
|
3070
|
-
return cache[`sub:${f.equals}`]?.has(c.externalId) ?? false;
|
|
3071
|
-
case "firedEvent":
|
|
3072
|
-
return cache[`evt:${f.eventName}`]?.has(c.externalId) ?? false;
|
|
3073
|
-
case "notFiredEvent":
|
|
3074
|
-
return !cache[`evt:${f.eventName}`]?.has(c.externalId);
|
|
3075
|
-
case "notHasTag":
|
|
3076
|
-
return !c.tags.includes(f.tag);
|
|
3077
|
-
case "opened":
|
|
3078
|
-
case "notOpened":
|
|
3079
|
-
return true;
|
|
3080
|
-
case "subscribedAfter":
|
|
3081
|
-
case "subscribedBefore":
|
|
3082
|
-
return true;
|
|
3083
|
-
// V2
|
|
3084
|
-
case "any":
|
|
3085
|
-
return f.filters.some((sub) => filterMatches(c, sub, cache));
|
|
3086
|
-
case "not":
|
|
3087
|
-
return !filterMatches(c, f.filter, cache);
|
|
3088
|
-
default:
|
|
3089
|
-
return true;
|
|
3674
|
+
}
|
|
3675
|
+
await Promise.all(
|
|
3676
|
+
inserted.map(
|
|
3677
|
+
({ sendId, delayMs }) => ctx.queues.send.add(
|
|
3678
|
+
"send",
|
|
3679
|
+
{ sendId: String(sendId) },
|
|
3680
|
+
{
|
|
3681
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
3682
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
3683
|
+
...delayMs > 0 ? { delay: delayMs } : {}
|
|
3684
|
+
}
|
|
3685
|
+
)
|
|
3686
|
+
)
|
|
3687
|
+
);
|
|
3688
|
+
if (capReached) break;
|
|
3689
|
+
}
|
|
3690
|
+
const now = /* @__PURE__ */ new Date();
|
|
3691
|
+
const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
3692
|
+
const set2 = capReached ? {
|
|
3693
|
+
status: "paused",
|
|
3694
|
+
pausedAt: now,
|
|
3695
|
+
pauseReason: {
|
|
3696
|
+
code: "cap_reached",
|
|
3697
|
+
message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
|
|
3698
|
+
at: now,
|
|
3699
|
+
details: { recipientCap: cap, sendsSoFar: total }
|
|
3700
|
+
}
|
|
3701
|
+
} : { status: "sent", completedAt: now };
|
|
3702
|
+
await ctx.collections.broadcasts.updateOne(
|
|
3703
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
3704
|
+
{ $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
|
|
3705
|
+
);
|
|
3706
|
+
} finally {
|
|
3707
|
+
await ctx.collections.broadcasts.updateOne(
|
|
3708
|
+
{ _id: b._id, dispatchLeaseId: leaseId },
|
|
3709
|
+
{ $set: { dispatchLeaseId: null } }
|
|
3710
|
+
);
|
|
3090
3711
|
}
|
|
3091
3712
|
}
|
|
3092
|
-
|
|
3093
|
-
const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
|
|
3094
|
-
if (supp.suppressed) return { doc: null, delayMs: 0 };
|
|
3713
|
+
function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
|
|
3095
3714
|
const sendId = new ObjectId();
|
|
3096
|
-
const dedupeKey =
|
|
3097
|
-
|
|
3715
|
+
const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
|
|
3716
|
+
const now = Date.now();
|
|
3717
|
+
let delayMs = Math.max(0, scheduledMs - now);
|
|
3098
3718
|
if (respectTimezone && contact.timezone) {
|
|
3099
|
-
|
|
3100
|
-
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
3719
|
+
delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
|
|
3101
3720
|
}
|
|
3102
3721
|
const doc = {
|
|
3103
3722
|
_id: sendId,
|
|
@@ -3132,16 +3751,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
3132
3751
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
3133
3752
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
3134
3753
|
sentAt: null,
|
|
3135
|
-
deliveredAt: null
|
|
3754
|
+
deliveredAt: null,
|
|
3755
|
+
notBefore: new Date(Date.now() + delayMs)
|
|
3136
3756
|
};
|
|
3137
3757
|
return { doc, delayMs };
|
|
3138
3758
|
}
|
|
3759
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3760
|
+
var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
|
|
3761
|
+
function recipientSlotMs(scheduledMs, timezone, nowMs) {
|
|
3762
|
+
let anchor = scheduledMs;
|
|
3763
|
+
let target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
3764
|
+
const earliest = nowMs - TZ_SLOT_GRACE_MS;
|
|
3765
|
+
if (target < earliest) {
|
|
3766
|
+
anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
|
|
3767
|
+
target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
3768
|
+
if (target < earliest) target += DAY_MS;
|
|
3769
|
+
}
|
|
3770
|
+
return target;
|
|
3771
|
+
}
|
|
3139
3772
|
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
3140
|
-
const DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
3141
3773
|
try {
|
|
3142
3774
|
const scheduled = new Date(scheduledMs);
|
|
3143
|
-
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC",
|
|
3144
|
-
const local = scheduled.toLocaleString("en-US", { timeZone: timezone,
|
|
3775
|
+
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
|
|
3776
|
+
const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
|
|
3145
3777
|
const parse = (s) => {
|
|
3146
3778
|
const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
|
|
3147
3779
|
if (!m) return 0;
|
|
@@ -3150,7 +3782,7 @@ function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
|
3150
3782
|
const utcMs = parse(utc);
|
|
3151
3783
|
const localMs = parse(local);
|
|
3152
3784
|
const offsetMs = utcMs - localMs;
|
|
3153
|
-
return (offsetMs %
|
|
3785
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
3154
3786
|
} catch {
|
|
3155
3787
|
return 0;
|
|
3156
3788
|
}
|
|
@@ -3261,10 +3893,10 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
3261
3893
|
}
|
|
3262
3894
|
let listedCount = 0;
|
|
3263
3895
|
async function processPair(p) {
|
|
3264
|
-
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
3265
|
-
const
|
|
3266
|
-
if (
|
|
3267
|
-
if (
|
|
3896
|
+
const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
|
|
3897
|
+
const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
3898
|
+
if (lookup2.transient) return;
|
|
3899
|
+
if (lookup2.result === "listed") listedCount++;
|
|
3268
3900
|
await ctx.collections.dnsblChecks.updateOne(
|
|
3269
3901
|
{ target: p.target, list: p.list.host },
|
|
3270
3902
|
{
|
|
@@ -3273,9 +3905,9 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
3273
3905
|
targetKind: p.targetKind,
|
|
3274
3906
|
list: p.list.host,
|
|
3275
3907
|
listLabel: p.list.label,
|
|
3276
|
-
result:
|
|
3277
|
-
returnCodes:
|
|
3278
|
-
errorMessage:
|
|
3908
|
+
result: lookup2.result,
|
|
3909
|
+
returnCodes: lookup2.returnCodes,
|
|
3910
|
+
errorMessage: lookup2.errorMessage,
|
|
3279
3911
|
runAt: /* @__PURE__ */ new Date()
|
|
3280
3912
|
}
|
|
3281
3913
|
},
|
|
@@ -3308,6 +3940,10 @@ function collectTargets(ctx, cfg) {
|
|
|
3308
3940
|
ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
|
|
3309
3941
|
};
|
|
3310
3942
|
}
|
|
3943
|
+
function spamhausQueryHost(listHost, dqsKey) {
|
|
3944
|
+
const zone = dqsKey ? /^([a-z0-9-]+)\.spamhaus\.org$/i.exec(listHost)?.[1] : void 0;
|
|
3945
|
+
return zone ? `${dqsKey}.${zone}.dq.spamhaus.net` : listHost;
|
|
3946
|
+
}
|
|
3311
3947
|
function buildQueryName(target, kind, listHost) {
|
|
3312
3948
|
if (kind === "ip") {
|
|
3313
3949
|
const v = net.isIP(target);
|
|
@@ -3343,16 +3979,22 @@ async function queryDnsbl(resolver, query) {
|
|
|
3343
3979
|
};
|
|
3344
3980
|
}
|
|
3345
3981
|
}
|
|
3982
|
+
var REFUSAL_REASONS = {
|
|
3983
|
+
"127.255.255.252": "malformed query name for this list",
|
|
3984
|
+
"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",
|
|
3985
|
+
"127.255.255.255": "this resolver has exceeded the free query limit"
|
|
3986
|
+
};
|
|
3346
3987
|
function interpretRecords(records) {
|
|
3347
3988
|
if (!records || records.length === 0) {
|
|
3348
3989
|
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
3349
3990
|
}
|
|
3350
3991
|
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
3351
3992
|
if (errorish.length === records.length) {
|
|
3993
|
+
const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
|
|
3352
3994
|
return {
|
|
3353
3995
|
result: "error",
|
|
3354
3996
|
returnCodes: records,
|
|
3355
|
-
errorMessage: `list
|
|
3997
|
+
errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
|
|
3356
3998
|
};
|
|
3357
3999
|
}
|
|
3358
4000
|
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
@@ -3919,17 +4561,17 @@ function parseDmarcReport(xml) {
|
|
|
3919
4561
|
const day = new Date(midMs).toISOString().slice(0, 10);
|
|
3920
4562
|
for (const rec of records) {
|
|
3921
4563
|
const row = rec?.row ?? {};
|
|
3922
|
-
const
|
|
3923
|
-
totalMessages +=
|
|
4564
|
+
const count2 = Number(row.count ?? 0) || 0;
|
|
4565
|
+
totalMessages += count2;
|
|
3924
4566
|
const evald = row.policy_evaluated ?? {};
|
|
3925
4567
|
const dkim = evald.dkim ?? "none";
|
|
3926
4568
|
const spf = evald.spf ?? "none";
|
|
3927
4569
|
const aligned = dkim === "pass" || spf === "pass";
|
|
3928
4570
|
if (aligned) {
|
|
3929
|
-
passCount +=
|
|
4571
|
+
passCount += count2;
|
|
3930
4572
|
continue;
|
|
3931
4573
|
}
|
|
3932
|
-
failCount +=
|
|
4574
|
+
failCount += count2;
|
|
3933
4575
|
const sourceIp = String(row.source_ip ?? "").trim();
|
|
3934
4576
|
if (!sourceIp) continue;
|
|
3935
4577
|
const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
|
|
@@ -3938,7 +4580,7 @@ function parseDmarcReport(xml) {
|
|
|
3938
4580
|
reportId,
|
|
3939
4581
|
domain,
|
|
3940
4582
|
sourceIp,
|
|
3941
|
-
count,
|
|
4583
|
+
count: count2,
|
|
3942
4584
|
headerFrom,
|
|
3943
4585
|
dkimResult: dkim,
|
|
3944
4586
|
spfResult: spf,
|
|
@@ -4083,11 +4725,11 @@ function suggestPolicyProgression(input) {
|
|
|
4083
4725
|
return null;
|
|
4084
4726
|
}
|
|
4085
4727
|
if (currentPolicy === "quarantine") {
|
|
4086
|
-
const
|
|
4728
|
+
const pct2 = currentPct ?? 100;
|
|
4087
4729
|
if (alignmentRate < 0.995) return null;
|
|
4088
|
-
if (
|
|
4089
|
-
if (
|
|
4090
|
-
if (
|
|
4730
|
+
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.` };
|
|
4731
|
+
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.` };
|
|
4732
|
+
if (pct2 < 100) return { policy: "quarantine", pct: 100, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct2} to 100.` };
|
|
4091
4733
|
if (alignmentRate >= 0.999) {
|
|
4092
4734
|
return { policy: "reject", pct: 100, reason: `Alignment at ${(alignmentRate * 100).toFixed(3)}% with policy=quarantine pct=100 \u2014 safe to move to p=reject.` };
|
|
4093
4735
|
}
|
|
@@ -4398,6 +5040,9 @@ async function runTick(ctx) {
|
|
|
4398
5040
|
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
4399
5041
|
console.error("mailery: stalled-broadcast resume failed", err);
|
|
4400
5042
|
});
|
|
5043
|
+
await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
|
|
5044
|
+
console.error("mailery: broadcast stop-rule sweep failed", err);
|
|
5045
|
+
});
|
|
4401
5046
|
await evaluateHealth(ctx).catch((err) => {
|
|
4402
5047
|
console.error("mailery: health evaluation failed", err);
|
|
4403
5048
|
});
|
|
@@ -5799,7 +6444,7 @@ async function checkDnsbl(mailer) {
|
|
|
5799
6444
|
label: "DNS block-list checks",
|
|
5800
6445
|
severity: "warn",
|
|
5801
6446
|
message: `all ${docs.length} checks errored`,
|
|
5802
|
-
hint: "Lookups against block lists are failing \u2014 usually a DNS-resolution problem on the host running mailery, or a list
|
|
6447
|
+
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."
|
|
5803
6448
|
};
|
|
5804
6449
|
}
|
|
5805
6450
|
return {
|
|
@@ -6105,7 +6750,7 @@ function humanDuration(ms) {
|
|
|
6105
6750
|
}
|
|
6106
6751
|
|
|
6107
6752
|
// src/server/runner/hygiene.ts
|
|
6108
|
-
var
|
|
6753
|
+
var DAY_MS2 = 864e5;
|
|
6109
6754
|
var HYGIENE_BUCKETS = [
|
|
6110
6755
|
{ label: "Engaged (last 30 days)", maxDays: 30 },
|
|
6111
6756
|
{ label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
|
|
@@ -6118,7 +6763,7 @@ async function computeListHygiene(ctx, opts = {}) {
|
|
|
6118
6763
|
const now = opts.now ?? Date.now();
|
|
6119
6764
|
const sunsetDays = opts.sunsetThresholdDays ?? 180;
|
|
6120
6765
|
const recentDays = opts.recentWindowDays ?? 90;
|
|
6121
|
-
const recentCutoff = new Date(now - recentDays *
|
|
6766
|
+
const recentCutoff = new Date(now - recentDays * DAY_MS2);
|
|
6122
6767
|
const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
|
|
6123
6768
|
const subscribedIds = /* @__PURE__ */ new Set();
|
|
6124
6769
|
{
|
|
@@ -6191,11 +6836,11 @@ async function computeListHygiene(ctx, opts = {}) {
|
|
|
6191
6836
|
if (lastEngaged == null) {
|
|
6192
6837
|
const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
|
|
6193
6838
|
buckets[5].count++;
|
|
6194
|
-
if (firstSent != null && now - firstSent > sunsetDays *
|
|
6839
|
+
if (firstSent != null && now - firstSent > sunsetDays * DAY_MS2) {
|
|
6195
6840
|
isSunset = true;
|
|
6196
6841
|
}
|
|
6197
6842
|
} else {
|
|
6198
|
-
const daysSince = (now - lastEngaged) /
|
|
6843
|
+
const daysSince = (now - lastEngaged) / DAY_MS2;
|
|
6199
6844
|
if (daysSince <= 30) buckets[0].count++;
|
|
6200
6845
|
else if (daysSince <= 60) buckets[1].count++;
|
|
6201
6846
|
else if (daysSince <= 90) buckets[2].count++;
|
|
@@ -6394,6 +7039,462 @@ async function evaluateMailTesterGate(ctx, input) {
|
|
|
6394
7039
|
}
|
|
6395
7040
|
return { allowed: true, reason: null, score: cached, code: null };
|
|
6396
7041
|
}
|
|
7042
|
+
var BroadcastOperationError = class extends Error {
|
|
7043
|
+
constructor(code, message, status = 400, details) {
|
|
7044
|
+
super(message);
|
|
7045
|
+
this.code = code;
|
|
7046
|
+
this.status = status;
|
|
7047
|
+
this.details = details;
|
|
7048
|
+
this.name = "BroadcastOperationError";
|
|
7049
|
+
}
|
|
7050
|
+
code;
|
|
7051
|
+
status;
|
|
7052
|
+
details;
|
|
7053
|
+
};
|
|
7054
|
+
var DEFAULT_SEGMENT = {
|
|
7055
|
+
filters: [{ kind: "subscriptionStatus", equals: "subscribed" }]
|
|
7056
|
+
};
|
|
7057
|
+
var MAX_RECIPIENT_CAP = 1e7;
|
|
7058
|
+
var ORDER_FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
7059
|
+
var recipientCapSchema = z.number().int().positive().max(MAX_RECIPIENT_CAP).nullable();
|
|
7060
|
+
var orderSchema = z.object({
|
|
7061
|
+
field: z.string().max(128).regex(ORDER_FIELD_RE, "must be a plain field path, e.g. updatedAt"),
|
|
7062
|
+
direction: z.enum(["asc", "desc"])
|
|
7063
|
+
}).nullable();
|
|
7064
|
+
var pctSchema = z.number().min(0).max(100);
|
|
7065
|
+
var stopRulesSchema = z.object({
|
|
7066
|
+
enabled: z.boolean().optional(),
|
|
7067
|
+
hardBounceRatePct: pctSchema.optional(),
|
|
7068
|
+
complaintRatePct: pctSchema.optional(),
|
|
7069
|
+
unsubscribeRatePct: pctSchema.optional(),
|
|
7070
|
+
minSample: z.number().int().min(1).max(1e6).optional()
|
|
7071
|
+
}).strict().nullable();
|
|
7072
|
+
var agentCreateBroadcastSchema = z.object({
|
|
7073
|
+
slug: slugSchema,
|
|
7074
|
+
name: z.string().min(1).max(200),
|
|
7075
|
+
templateSlug: slugSchema,
|
|
7076
|
+
segmentDefinition: z.object({ filters: z.array(z.any()) }).optional(),
|
|
7077
|
+
respectRecipientTimezone: z.boolean().optional(),
|
|
7078
|
+
recipientCap: recipientCapSchema.optional(),
|
|
7079
|
+
order: orderSchema.optional(),
|
|
7080
|
+
stopRules: stopRulesSchema.optional()
|
|
7081
|
+
});
|
|
7082
|
+
var agentPatchBroadcastSchema = z.object({
|
|
7083
|
+
name: z.string().min(1).max(200).optional(),
|
|
7084
|
+
templateSlug: slugSchema.optional(),
|
|
7085
|
+
segmentDefinition: z.object({ filters: z.array(z.any()) }).optional(),
|
|
7086
|
+
respectRecipientTimezone: z.boolean().optional(),
|
|
7087
|
+
recipientCap: recipientCapSchema.optional(),
|
|
7088
|
+
order: orderSchema.optional(),
|
|
7089
|
+
stopRules: stopRulesSchema.optional()
|
|
7090
|
+
});
|
|
7091
|
+
var agentResumeBroadcastSchema = z.object({
|
|
7092
|
+
recipientCap: recipientCapSchema.optional(),
|
|
7093
|
+
stopRules: stopRulesSchema.optional(),
|
|
7094
|
+
confirmedCount: z.number().int().nonnegative()
|
|
7095
|
+
});
|
|
7096
|
+
var agentPauseBroadcastSchema = z.object({
|
|
7097
|
+
reason: z.string().max(500).optional()
|
|
7098
|
+
});
|
|
7099
|
+
function checkWaveSettings(mailer, input) {
|
|
7100
|
+
if (input.stopRules !== void 0 && !stopRulesSchema.safeParse(input.stopRules).success) {
|
|
7101
|
+
throw new BroadcastOperationError(
|
|
7102
|
+
"invalid_stop_rules",
|
|
7103
|
+
"stopRules must be {enabled?, hardBounceRatePct?, complaintRatePct?, unsubscribeRatePct?, minSample?} (percentages 0-100), or null"
|
|
7104
|
+
);
|
|
7105
|
+
}
|
|
7106
|
+
if (input.recipientCap !== void 0 && !recipientCapSchema.safeParse(input.recipientCap).success) {
|
|
7107
|
+
throw new BroadcastOperationError("invalid_recipient_cap", `recipientCap must be a positive integer up to ${MAX_RECIPIENT_CAP}, or null`);
|
|
7108
|
+
}
|
|
7109
|
+
if (input.order !== void 0) {
|
|
7110
|
+
if (!orderSchema.safeParse(input.order).success) {
|
|
7111
|
+
throw new BroadcastOperationError("invalid_order", 'order must be {field: "<host field path>", direction: "asc" | "desc"}, or null');
|
|
7112
|
+
}
|
|
7113
|
+
if (input.order !== null && !mailer.adapter.supportsSort) {
|
|
7114
|
+
throw new BroadcastOperationError(
|
|
7115
|
+
"adapter_cannot_sort",
|
|
7116
|
+
"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",
|
|
7117
|
+
422
|
|
7118
|
+
);
|
|
7119
|
+
}
|
|
7120
|
+
}
|
|
7121
|
+
}
|
|
7122
|
+
var agentScheduleBroadcastSchema = z.object({
|
|
7123
|
+
scheduledAt: z.string().min(1),
|
|
7124
|
+
confirmedCount: z.number().int().nonnegative(),
|
|
7125
|
+
respectRecipientTimezone: z.boolean().optional()
|
|
7126
|
+
});
|
|
7127
|
+
function parseSegment(seg, strict) {
|
|
7128
|
+
const r = segmentDefinitionSchema(strict).safeParse(seg);
|
|
7129
|
+
if (!r.success) {
|
|
7130
|
+
throw new BroadcastOperationError(
|
|
7131
|
+
"invalid_segment",
|
|
7132
|
+
`segmentDefinition: ${r.error.issues.map((i) => `${i.path.join(".") || "filters"}: ${i.message}`).join("; ")}`,
|
|
7133
|
+
400
|
|
7134
|
+
);
|
|
7135
|
+
}
|
|
7136
|
+
return r.data;
|
|
7137
|
+
}
|
|
7138
|
+
function assertSubscribedSegment(seg) {
|
|
7139
|
+
const filters = Array.isArray(seg?.filters) ? seg.filters : [];
|
|
7140
|
+
const ok = filters.some((f) => f && f.kind === "subscriptionStatus" && f.equals === "subscribed");
|
|
7141
|
+
if (!ok) {
|
|
7142
|
+
throw new BroadcastOperationError(
|
|
7143
|
+
"segment_requires_subscribed",
|
|
7144
|
+
'the agent API only schedules broadcasts to subscribed contacts: add {"kind": "subscriptionStatus", "equals": "subscribed"} to segmentDefinition.filters (top level)',
|
|
7145
|
+
422
|
|
7146
|
+
);
|
|
7147
|
+
}
|
|
7148
|
+
}
|
|
7149
|
+
async function loadBroadcast(mailer, slug) {
|
|
7150
|
+
const b = await mailer.collections.broadcasts.findOne({ slug });
|
|
7151
|
+
if (!b) throw new BroadcastOperationError("not_found", `no broadcast with slug "${slug}"`, 404);
|
|
7152
|
+
return b;
|
|
7153
|
+
}
|
|
7154
|
+
async function loadBroadcastTemplate(mailer, b) {
|
|
7155
|
+
const tpl = await mailer.collections.templates.findOne({ slug: b.templateSlug });
|
|
7156
|
+
if (!tpl) {
|
|
7157
|
+
throw new BroadcastOperationError("template_not_found", `template "${b.templateSlug}" does not exist`, 409);
|
|
7158
|
+
}
|
|
7159
|
+
return tpl;
|
|
7160
|
+
}
|
|
7161
|
+
async function countRecipients(mailer, b, segment, overrides = {}) {
|
|
7162
|
+
const tpl = await loadBroadcastTemplate(mailer, b);
|
|
7163
|
+
const count2 = await countBroadcastRecipients(
|
|
7164
|
+
{
|
|
7165
|
+
_id: b._id,
|
|
7166
|
+
segmentDefinition: segment ?? b.segmentDefinition,
|
|
7167
|
+
order: b.order ?? null,
|
|
7168
|
+
recipientCap: overrides.recipientCap !== void 0 ? overrides.recipientCap : b.recipientCap ?? null
|
|
7169
|
+
},
|
|
7170
|
+
tpl.kind,
|
|
7171
|
+
mailer.getRunnerContext()
|
|
7172
|
+
);
|
|
7173
|
+
const heldSends = b._id ? await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: "held" }) : 0;
|
|
7174
|
+
return { ...count2, heldSends, templateKind: tpl.kind };
|
|
7175
|
+
}
|
|
7176
|
+
function stopRuleStatus(mailer, b, stats) {
|
|
7177
|
+
return evaluateStopRules(stats, effectiveStopRules(mailer.config, b));
|
|
7178
|
+
}
|
|
7179
|
+
async function pauseBroadcastByOperator(mailer, slug, input, actor) {
|
|
7180
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7181
|
+
if (b.status !== "sending" && b.status !== "sent") {
|
|
7182
|
+
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);
|
|
7183
|
+
}
|
|
7184
|
+
const out = await pauseBroadcast(
|
|
7185
|
+
mailer.getRunnerContext(),
|
|
7186
|
+
b._id,
|
|
7187
|
+
{ code: "manual", message: input.reason?.trim() || `paused by ${actor}`, at: /* @__PURE__ */ new Date(), details: { actor } },
|
|
7188
|
+
actor
|
|
7189
|
+
);
|
|
7190
|
+
if (!out.paused) throw new BroadcastOperationError("not_pausable", "broadcast changed state while being paused", 409);
|
|
7191
|
+
return { broadcast: await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? b, heldSends: out.heldSends };
|
|
7192
|
+
}
|
|
7193
|
+
async function createBroadcast(mailer, input, actor, opts = {}) {
|
|
7194
|
+
const { slug, name, templateSlug } = input;
|
|
7195
|
+
if (!slug || !name || !templateSlug) {
|
|
7196
|
+
throw new BroadcastOperationError("validation_failed", "slug, name, templateSlug required");
|
|
7197
|
+
}
|
|
7198
|
+
const segmentDefinition = input.segmentDefinition ? parseSegment(input.segmentDefinition, !!opts.strictSegment) : DEFAULT_SEGMENT;
|
|
7199
|
+
if (opts.requireSubscribed) assertSubscribedSegment(segmentDefinition);
|
|
7200
|
+
checkWaveSettings(mailer, input);
|
|
7201
|
+
await assertBroadcastTemplate(mailer, templateSlug, { mustExist: false });
|
|
7202
|
+
const now = /* @__PURE__ */ new Date();
|
|
7203
|
+
const doc = {
|
|
7204
|
+
slug,
|
|
7205
|
+
name,
|
|
7206
|
+
templateSlug,
|
|
7207
|
+
segmentDefinition,
|
|
7208
|
+
status: "draft",
|
|
7209
|
+
scheduledAt: null,
|
|
7210
|
+
startedAt: null,
|
|
7211
|
+
completedAt: null,
|
|
7212
|
+
confirmationRequired: true,
|
|
7213
|
+
confirmedCount: null,
|
|
7214
|
+
confirmedAt: null,
|
|
7215
|
+
confirmedBy: null,
|
|
7216
|
+
recipientCount: null,
|
|
7217
|
+
stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
|
|
7218
|
+
createdAt: now,
|
|
7219
|
+
createdBy: actor,
|
|
7220
|
+
updatedAt: now
|
|
7221
|
+
};
|
|
7222
|
+
if (input.respectRecipientTimezone) doc.respectRecipientTimezone = true;
|
|
7223
|
+
if (input.recipientCap !== void 0) doc.recipientCap = input.recipientCap;
|
|
7224
|
+
if (input.order !== void 0) doc.order = input.order;
|
|
7225
|
+
if (input.stopRules !== void 0) doc.stopRules = input.stopRules;
|
|
7226
|
+
try {
|
|
7227
|
+
const res = await mailer.collections.broadcasts.insertOne(doc);
|
|
7228
|
+
doc._id = res.insertedId;
|
|
7229
|
+
} catch (err) {
|
|
7230
|
+
if (err?.code === 11e3) throw new BroadcastOperationError("slug_taken", `slug "${slug}" is taken`, 409);
|
|
7231
|
+
throw err;
|
|
7232
|
+
}
|
|
7233
|
+
await mailer.audit({
|
|
7234
|
+
actor,
|
|
7235
|
+
action: "broadcast.create",
|
|
7236
|
+
resource: { collection: "mailer_broadcasts", id: doc._id, slug }
|
|
7237
|
+
});
|
|
7238
|
+
return doc;
|
|
7239
|
+
}
|
|
7240
|
+
async function patchBroadcast(mailer, slug, patch, actor, opts = {}) {
|
|
7241
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7242
|
+
if (b.status !== "draft") {
|
|
7243
|
+
throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be edited`, 409);
|
|
7244
|
+
}
|
|
7245
|
+
const segmentDefinition = patch.segmentDefinition ? parseSegment(patch.segmentDefinition, !!opts.strictSegment) : void 0;
|
|
7246
|
+
if (opts.requireSubscribed && segmentDefinition) assertSubscribedSegment(segmentDefinition);
|
|
7247
|
+
checkWaveSettings(mailer, patch);
|
|
7248
|
+
if (typeof patch.templateSlug === "string") await assertBroadcastTemplate(mailer, patch.templateSlug, { mustExist: false });
|
|
7249
|
+
const set2 = { updatedAt: /* @__PURE__ */ new Date() };
|
|
7250
|
+
if (patch.recipientCap !== void 0) set2.recipientCap = patch.recipientCap;
|
|
7251
|
+
if (patch.order !== void 0) set2.order = patch.order;
|
|
7252
|
+
if (patch.stopRules !== void 0) set2.stopRules = patch.stopRules;
|
|
7253
|
+
if (typeof patch.name === "string") set2.name = patch.name;
|
|
7254
|
+
if (typeof patch.templateSlug === "string") set2.templateSlug = patch.templateSlug;
|
|
7255
|
+
if (segmentDefinition) set2.segmentDefinition = segmentDefinition;
|
|
7256
|
+
if (typeof patch.respectRecipientTimezone === "boolean") set2.respectRecipientTimezone = patch.respectRecipientTimezone;
|
|
7257
|
+
const res = await mailer.collections.broadcasts.findOneAndUpdate(
|
|
7258
|
+
{ _id: b._id, status: "draft" },
|
|
7259
|
+
{ $set: set2 },
|
|
7260
|
+
{ returnDocument: "after" }
|
|
7261
|
+
);
|
|
7262
|
+
if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being edited", 409);
|
|
7263
|
+
await mailer.audit({
|
|
7264
|
+
actor,
|
|
7265
|
+
action: "broadcast.update",
|
|
7266
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug },
|
|
7267
|
+
diffSummary: Object.keys(set2).filter((k) => k !== "updatedAt").join(", ")
|
|
7268
|
+
});
|
|
7269
|
+
return res;
|
|
7270
|
+
}
|
|
7271
|
+
async function scheduleBroadcast(mailer, slug, input, actor, opts = {}) {
|
|
7272
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7273
|
+
if (b.status !== "draft") {
|
|
7274
|
+
throw new BroadcastOperationError("not_draft", `broadcast is ${b.status}; only a draft can be scheduled`, 409);
|
|
7275
|
+
}
|
|
7276
|
+
if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
|
|
7277
|
+
parseSegment(b.segmentDefinition, true);
|
|
7278
|
+
await assertBroadcastTemplate(mailer, b.templateSlug, { mustExist: true });
|
|
7279
|
+
if (!input.scheduledAt) throw new BroadcastOperationError("scheduledAt_required", "scheduledAt is required");
|
|
7280
|
+
const scheduled = new Date(input.scheduledAt);
|
|
7281
|
+
if (Number.isNaN(scheduled.getTime())) throw new BroadcastOperationError("bad_scheduledAt", "scheduledAt is not a date");
|
|
7282
|
+
if (typeof input.confirmedCount !== "number") {
|
|
7283
|
+
throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
|
|
7284
|
+
}
|
|
7285
|
+
const confirmedCount = input.confirmedCount;
|
|
7286
|
+
const threshold = mailer.config.broadcastConfirmationThreshold;
|
|
7287
|
+
if (opts.requireExactCount) {
|
|
7288
|
+
const { recipientCount } = await countRecipients(mailer, b);
|
|
7289
|
+
if (confirmedCount !== recipientCount) {
|
|
7290
|
+
throw new BroadcastOperationError(
|
|
7291
|
+
"count_mismatch",
|
|
7292
|
+
`confirmedCount ${confirmedCount} does not match the ${recipientCount} recipient(s) this broadcast would send to now; recount with POST /broadcasts/${b.slug}/count`,
|
|
7293
|
+
409,
|
|
7294
|
+
{ expected: recipientCount, confirmedCount }
|
|
7295
|
+
);
|
|
7296
|
+
}
|
|
7297
|
+
}
|
|
7298
|
+
const set2 = {
|
|
7299
|
+
status: "scheduled",
|
|
7300
|
+
scheduledAt: scheduled,
|
|
7301
|
+
confirmedCount,
|
|
7302
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
7303
|
+
confirmedBy: actor,
|
|
7304
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
7305
|
+
};
|
|
7306
|
+
if (input.respectRecipientTimezone) set2.respectRecipientTimezone = true;
|
|
7307
|
+
const res = await mailer.collections.broadcasts.findOneAndUpdate(
|
|
7308
|
+
{ _id: b._id, status: "draft" },
|
|
7309
|
+
{ $set: set2 },
|
|
7310
|
+
{ returnDocument: "after" }
|
|
7311
|
+
);
|
|
7312
|
+
if (!res) throw new BroadcastOperationError("not_draft", "broadcast left draft while being scheduled", 409);
|
|
7313
|
+
await mailer.audit({
|
|
7314
|
+
actor,
|
|
7315
|
+
action: "broadcast.schedule",
|
|
7316
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7317
|
+
diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
|
|
7318
|
+
});
|
|
7319
|
+
return res;
|
|
7320
|
+
}
|
|
7321
|
+
async function resumeBroadcast(mailer, slug, input, actor, opts = {}) {
|
|
7322
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7323
|
+
if (b.status !== "paused") {
|
|
7324
|
+
throw new BroadcastOperationError("not_paused", `broadcast is ${b.status}; only a paused broadcast can be resumed`, 409);
|
|
7325
|
+
}
|
|
7326
|
+
if (opts.requireSubscribed) assertSubscribedSegment(b.segmentDefinition);
|
|
7327
|
+
checkWaveSettings(mailer, { recipientCap: input.recipientCap, stopRules: input.stopRules });
|
|
7328
|
+
const cap = input.recipientCap !== void 0 ? input.recipientCap : b.recipientCap ?? null;
|
|
7329
|
+
const stopRules = input.stopRules === void 0 ? b.stopRules ?? null : input.stopRules === null ? null : { ...b.stopRules ?? {}, ...input.stopRules };
|
|
7330
|
+
if (b.pauseReason?.code === "stop_rule") {
|
|
7331
|
+
const evaluation = evaluateStopRules(
|
|
7332
|
+
await broadcastStatsFor(mailer.collections, b._id),
|
|
7333
|
+
effectiveStopRules(mailer.config, { stopRules })
|
|
7334
|
+
);
|
|
7335
|
+
if (evaluation.breaches.length > 0) {
|
|
7336
|
+
throw new BroadcastOperationError(
|
|
7337
|
+
"stop_rule_still_breached",
|
|
7338
|
+
`the stop rules still fire (${evaluation.breaches.map((x) => `${x.rule} ${x.ratePct}% > ${x.thresholdPct}%`).join(", ")}); investigate, then resume with adjusted stopRules to override`,
|
|
7339
|
+
409,
|
|
7340
|
+
{ evaluation }
|
|
7341
|
+
);
|
|
7342
|
+
}
|
|
7343
|
+
}
|
|
7344
|
+
if (b.pauseReason?.code === "circuit_breaker") {
|
|
7345
|
+
const tpl = await loadBroadcastTemplate(mailer, b);
|
|
7346
|
+
const bucket = await getBucketStatus(mailer.getRunnerContext(), tpl.fromEmail, tpl.kind);
|
|
7347
|
+
if (bucket?.status === "tripped") {
|
|
7348
|
+
throw new BroadcastOperationError(
|
|
7349
|
+
"circuit_breaker_tripped",
|
|
7350
|
+
`the ${bucket.senderDomain ?? "sender"} ${tpl.kind} circuit breaker is still tripped (${bucket.trippedReason ?? "no reason recorded"}); resume it first (POST /api/health/resume)`,
|
|
7351
|
+
409,
|
|
7352
|
+
{ bucket: bucket._id, trippedReason: bucket.trippedReason }
|
|
7353
|
+
);
|
|
7354
|
+
}
|
|
7355
|
+
}
|
|
7356
|
+
const sendsSoFar = await mailer.collections.sends.countDocuments({ broadcastId: b._id });
|
|
7357
|
+
if (cap !== null && cap <= sendsSoFar && b.pauseReason?.code === "cap_reached") {
|
|
7358
|
+
throw new BroadcastOperationError(
|
|
7359
|
+
"cap_not_raised",
|
|
7360
|
+
`${sendsSoFar} send(s) already count against recipientCap ${cap}; pass a higher recipientCap (or null for no cap) to send the next wave`,
|
|
7361
|
+
409,
|
|
7362
|
+
{ recipientCap: cap, sendsSoFar }
|
|
7363
|
+
);
|
|
7364
|
+
}
|
|
7365
|
+
if (cap !== null && cap < sendsSoFar) {
|
|
7366
|
+
throw new BroadcastOperationError("cap_below_sent", `recipientCap ${cap} is below the ${sendsSoFar} send(s) already made`, 400);
|
|
7367
|
+
}
|
|
7368
|
+
let expected = null;
|
|
7369
|
+
if (opts.requireExactCount) {
|
|
7370
|
+
if (typeof input.confirmedCount !== "number") {
|
|
7371
|
+
throw new BroadcastOperationError("confirmedCount_required", "confirmedCount (number) is required");
|
|
7372
|
+
}
|
|
7373
|
+
const count2 = await countRecipients(mailer, b, void 0, { recipientCap: cap });
|
|
7374
|
+
expected = count2.recipientCount + count2.heldSends;
|
|
7375
|
+
if (input.confirmedCount !== expected) {
|
|
7376
|
+
throw new BroadcastOperationError(
|
|
7377
|
+
"count_mismatch",
|
|
7378
|
+
`confirmedCount ${input.confirmedCount} does not match the ${expected} send(s) resuming would release now (${count2.recipientCount} new + ${count2.heldSends} held)`,
|
|
7379
|
+
409,
|
|
7380
|
+
{ expected, recipientCount: count2.recipientCount, heldSends: count2.heldSends, confirmedCount: input.confirmedCount }
|
|
7381
|
+
);
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
const now = /* @__PURE__ */ new Date();
|
|
7385
|
+
const resumed = await mailer.collections.broadcasts.findOneAndUpdate(
|
|
7386
|
+
{ _id: b._id, status: "paused" },
|
|
7387
|
+
{
|
|
7388
|
+
$set: {
|
|
7389
|
+
status: "sending",
|
|
7390
|
+
recipientCap: cap,
|
|
7391
|
+
stopRules,
|
|
7392
|
+
pausedAt: null,
|
|
7393
|
+
pauseReason: null,
|
|
7394
|
+
dispatchLeaseId: null,
|
|
7395
|
+
updatedAt: now,
|
|
7396
|
+
...typeof input.confirmedCount === "number" ? { confirmedCount: input.confirmedCount, confirmedAt: now, confirmedBy: actor } : {}
|
|
7397
|
+
}
|
|
7398
|
+
},
|
|
7399
|
+
{ returnDocument: "after" }
|
|
7400
|
+
);
|
|
7401
|
+
if (!resumed) throw new BroadcastOperationError("not_paused", "broadcast left paused while being resumed", 409);
|
|
7402
|
+
await mailer.audit({
|
|
7403
|
+
actor,
|
|
7404
|
+
action: "broadcast.resume",
|
|
7405
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7406
|
+
diffSummary: `was paused (${b.pauseReason?.code ?? "unknown"}) \xB7 recipientCap ${b.recipientCap ?? "none"} \u2192 ${cap ?? "none"} \xB7 sendsSoFar=${sendsSoFar}${expected !== null ? ` \xB7 confirmedCount=${expected}` : ""}`
|
|
7407
|
+
});
|
|
7408
|
+
const ctx = mailer.getRunnerContext();
|
|
7409
|
+
await releaseHeldSends(ctx, b._id);
|
|
7410
|
+
await startBroadcastDispatch(resumed, ctx);
|
|
7411
|
+
return await mailer.collections.broadcasts.findOne({ _id: b._id }) ?? resumed;
|
|
7412
|
+
}
|
|
7413
|
+
async function cancelBroadcast(mailer, slug, actor) {
|
|
7414
|
+
const b = await loadBroadcast(mailer, slug);
|
|
7415
|
+
if (b.status === "cancelled") return { broadcast: b, cancelledSends: 0 };
|
|
7416
|
+
const pending = await mailer.collections.sends.countDocuments({ broadcastId: b._id, status: { $in: ["queued", "held"] } });
|
|
7417
|
+
if ((b.status === "sent" || b.status === "failed") && pending === 0) {
|
|
7418
|
+
throw new BroadcastOperationError("already_finished", `broadcast is ${b.status} with nothing left to send; there is nothing to cancel`, 409);
|
|
7419
|
+
}
|
|
7420
|
+
await mailer.collections.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
|
|
7421
|
+
const now = /* @__PURE__ */ new Date();
|
|
7422
|
+
const cancelled = await mailer.collections.sends.updateMany(
|
|
7423
|
+
{ broadcastId: b._id, status: { $in: ["queued", "held"] } },
|
|
7424
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: broadcast cancelled by ${actor}`, updatedAt: now } }
|
|
7425
|
+
);
|
|
7426
|
+
await mailer.audit({
|
|
7427
|
+
actor,
|
|
7428
|
+
action: "broadcast.cancel",
|
|
7429
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7430
|
+
diffSummary: `was ${b.status}; cancelled ${cancelled.modifiedCount} queued/held send(s)`
|
|
7431
|
+
});
|
|
7432
|
+
const after = await mailer.collections.broadcasts.findOne({ _id: b._id });
|
|
7433
|
+
return { broadcast: after ?? b, cancelledSends: cancelled.modifiedCount };
|
|
7434
|
+
}
|
|
7435
|
+
async function assertBroadcastTemplate(mailer, templateSlug, opts) {
|
|
7436
|
+
const tpl = await mailer.collections.templates.findOne({ slug: templateSlug });
|
|
7437
|
+
if (!tpl) {
|
|
7438
|
+
if (opts.mustExist) throw new BroadcastOperationError("template_not_found", `template "${templateSlug}" does not exist`, 409);
|
|
7439
|
+
return;
|
|
7440
|
+
}
|
|
7441
|
+
if (tpl.kind !== "marketing") {
|
|
7442
|
+
throw new BroadcastOperationError(
|
|
7443
|
+
"template_not_marketing",
|
|
7444
|
+
`template "${templateSlug}" is ${tpl.kind}; a broadcast needs a marketing template (List-Unsubscribe, marketing opt-outs, circuit breaker)`,
|
|
7445
|
+
409
|
|
7446
|
+
);
|
|
7447
|
+
}
|
|
7448
|
+
if (opts.mustExist && !tpl.body?.html && !tpl.body?.mjml) {
|
|
7449
|
+
throw new BroadcastOperationError("template_not_published", `template "${templateSlug}" has no published body`, 409);
|
|
7450
|
+
}
|
|
7451
|
+
}
|
|
7452
|
+
async function computeBroadcastStats(mailer, idFilter) {
|
|
7453
|
+
return aggregateBroadcastStats(mailer.collections, idFilter);
|
|
7454
|
+
}
|
|
7455
|
+
function capProgress(b, stats) {
|
|
7456
|
+
const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
|
|
7457
|
+
return {
|
|
7458
|
+
recipientCap: cap,
|
|
7459
|
+
sendsSoFar: stats.total,
|
|
7460
|
+
remaining: cap === null ? null : Math.max(0, cap - stats.total)
|
|
7461
|
+
};
|
|
7462
|
+
}
|
|
7463
|
+
async function broadcastStatusBreakdown(mailer, broadcastId) {
|
|
7464
|
+
const rows = await mailer.collections.sends.aggregate([
|
|
7465
|
+
{ $match: { broadcastId } },
|
|
7466
|
+
{ $group: { _id: "$status", n: { $sum: 1 } } }
|
|
7467
|
+
]).toArray();
|
|
7468
|
+
return Object.fromEntries(rows.map((r) => [r._id, r.n]));
|
|
7469
|
+
}
|
|
7470
|
+
function broadcastSummary(b) {
|
|
7471
|
+
return {
|
|
7472
|
+
id: String(b._id),
|
|
7473
|
+
slug: b.slug,
|
|
7474
|
+
name: b.name,
|
|
7475
|
+
templateSlug: b.templateSlug,
|
|
7476
|
+
status: b.status,
|
|
7477
|
+
segmentDefinition: b.segmentDefinition,
|
|
7478
|
+
respectRecipientTimezone: b.respectRecipientTimezone === true,
|
|
7479
|
+
recipientCap: b.recipientCap ?? null,
|
|
7480
|
+
order: b.order ?? null,
|
|
7481
|
+
pausedAt: b.pausedAt ?? null,
|
|
7482
|
+
pauseReason: b.pauseReason ?? null,
|
|
7483
|
+
stopRules: b.stopRules ?? null,
|
|
7484
|
+
stopRuleBreach: b.stopRuleBreach ?? null,
|
|
7485
|
+
failureReason: b.failureReason ?? null,
|
|
7486
|
+
scheduledAt: b.scheduledAt,
|
|
7487
|
+
startedAt: b.startedAt,
|
|
7488
|
+
completedAt: b.completedAt,
|
|
7489
|
+
confirmedCount: b.confirmedCount,
|
|
7490
|
+
confirmedAt: b.confirmedAt,
|
|
7491
|
+
confirmedBy: b.confirmedBy,
|
|
7492
|
+
recipientCount: b.recipientCount,
|
|
7493
|
+
createdAt: b.createdAt,
|
|
7494
|
+
createdBy: b.createdBy,
|
|
7495
|
+
updatedAt: b.updatedAt
|
|
7496
|
+
};
|
|
7497
|
+
}
|
|
6397
7498
|
|
|
6398
7499
|
// src/server/api/admin.ts
|
|
6399
7500
|
var __filename$1 = fileURLToPath(import.meta.url);
|
|
@@ -7196,19 +8297,19 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7196
8297
|
const flow = await c.flows.findOne({ slug: req.params.slug });
|
|
7197
8298
|
if (!flow) return res.status(404).json({ error: "not_found" });
|
|
7198
8299
|
const { steps, notes, trigger, name, description, goal, audience } = req.body ?? {};
|
|
7199
|
-
const
|
|
8300
|
+
const set2 = {
|
|
7200
8301
|
"draft.lastModifiedBy": req.actor,
|
|
7201
8302
|
"draft.lastModifiedAt": /* @__PURE__ */ new Date(),
|
|
7202
8303
|
updatedAt: /* @__PURE__ */ new Date()
|
|
7203
8304
|
};
|
|
7204
|
-
if (Array.isArray(steps))
|
|
7205
|
-
if (typeof notes === "string")
|
|
7206
|
-
if (trigger?.eventName)
|
|
7207
|
-
if (typeof name === "string")
|
|
7208
|
-
if (typeof description === "string")
|
|
7209
|
-
if (typeof goal === "string")
|
|
7210
|
-
if (typeof audience === "string")
|
|
7211
|
-
await c.flows.updateOne({ _id: flow._id }, { $set:
|
|
8305
|
+
if (Array.isArray(steps)) set2["draft.steps"] = steps;
|
|
8306
|
+
if (typeof notes === "string") set2["draft.notes"] = notes;
|
|
8307
|
+
if (trigger?.eventName) set2.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
|
|
8308
|
+
if (typeof name === "string") set2.name = name;
|
|
8309
|
+
if (typeof description === "string") set2.description = description;
|
|
8310
|
+
if (typeof goal === "string") set2.goal = goal;
|
|
8311
|
+
if (typeof audience === "string") set2.audience = audience;
|
|
8312
|
+
await c.flows.updateOne({ _id: flow._id }, { $set: set2 });
|
|
7212
8313
|
await mailer.audit({
|
|
7213
8314
|
actor: req.actor,
|
|
7214
8315
|
action: "flow.draft.update",
|
|
@@ -7369,25 +8470,25 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7369
8470
|
}
|
|
7370
8471
|
);
|
|
7371
8472
|
}
|
|
7372
|
-
const
|
|
8473
|
+
const set2 = {
|
|
7373
8474
|
"draft.lastModifiedBy": req.actor,
|
|
7374
8475
|
"draft.lastModifiedAt": /* @__PURE__ */ new Date(),
|
|
7375
8476
|
updatedAt: /* @__PURE__ */ new Date()
|
|
7376
8477
|
};
|
|
7377
|
-
if (typeof subject === "string")
|
|
7378
|
-
if (typeof preheader === "string")
|
|
7379
|
-
if (typeof mjml === "string")
|
|
7380
|
-
if (typeof html === "string")
|
|
7381
|
-
if (editorJson !== void 0)
|
|
7382
|
-
if (typeof notes === "string")
|
|
7383
|
-
if (typeof name === "string")
|
|
7384
|
-
if (typeof fromName === "string")
|
|
7385
|
-
if (typeof fromEmail === "string")
|
|
7386
|
-
if (typeof replyTo === "string" || replyTo === null)
|
|
7387
|
-
if (kind === "marketing" || kind === "transactional")
|
|
8478
|
+
if (typeof subject === "string") set2["draft.subject"] = subject;
|
|
8479
|
+
if (typeof preheader === "string") set2["draft.preheader"] = preheader;
|
|
8480
|
+
if (typeof mjml === "string") set2["draft.mjml"] = mjml;
|
|
8481
|
+
if (typeof html === "string") set2["draft.html"] = html;
|
|
8482
|
+
if (editorJson !== void 0) set2["draft.editorJson"] = editorJson;
|
|
8483
|
+
if (typeof notes === "string") set2["draft.notes"] = notes;
|
|
8484
|
+
if (typeof name === "string") set2.name = name;
|
|
8485
|
+
if (typeof fromName === "string") set2.fromName = fromName;
|
|
8486
|
+
if (typeof fromEmail === "string") set2.fromEmail = fromEmail;
|
|
8487
|
+
if (typeof replyTo === "string" || replyTo === null) set2.replyTo = replyTo;
|
|
8488
|
+
if (kind === "marketing" || kind === "transactional") set2.kind = kind;
|
|
7388
8489
|
if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
|
|
7389
|
-
const resultingKind =
|
|
7390
|
-
const resultingFromEmail =
|
|
8490
|
+
const resultingKind = set2.kind ?? tpl.kind;
|
|
8491
|
+
const resultingFromEmail = set2.fromEmail ?? tpl.fromEmail;
|
|
7391
8492
|
const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
|
|
7392
8493
|
if (!senderCheck.ok) {
|
|
7393
8494
|
return res.status(400).json({
|
|
@@ -7397,10 +8498,10 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7397
8498
|
});
|
|
7398
8499
|
}
|
|
7399
8500
|
}
|
|
7400
|
-
if (TEMPLATE_BODY_FORMATS.includes(bodyFormat))
|
|
7401
|
-
if (typeof trackOpens === "boolean")
|
|
7402
|
-
if (typeof trackClicks === "boolean")
|
|
7403
|
-
await c.templates.updateOne({ _id: tpl._id }, { $set:
|
|
8501
|
+
if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set2.bodyFormat = bodyFormat;
|
|
8502
|
+
if (typeof trackOpens === "boolean") set2.trackOpens = trackOpens;
|
|
8503
|
+
if (typeof trackClicks === "boolean") set2.trackClicks = trackClicks;
|
|
8504
|
+
await c.templates.updateOne({ _id: tpl._id }, { $set: set2 });
|
|
7404
8505
|
await mailer.audit({
|
|
7405
8506
|
actor: req.actor,
|
|
7406
8507
|
action: "template.draft.update",
|
|
@@ -7878,125 +8979,60 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7878
8979
|
);
|
|
7879
8980
|
r.post(
|
|
7880
8981
|
"/broadcasts",
|
|
7881
|
-
|
|
7882
|
-
const { slug, name, templateSlug, segmentDefinition } = req.body ?? {};
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
await c.broadcasts.insertOne({
|
|
7889
|
-
slug,
|
|
7890
|
-
name,
|
|
7891
|
-
templateSlug,
|
|
7892
|
-
segmentDefinition: segmentDefinition ?? { filters: [{ kind: "subscriptionStatus", equals: "subscribed" }] },
|
|
7893
|
-
status: "draft",
|
|
7894
|
-
scheduledAt: null,
|
|
7895
|
-
startedAt: null,
|
|
7896
|
-
completedAt: null,
|
|
7897
|
-
confirmationRequired: true,
|
|
7898
|
-
confirmedCount: null,
|
|
7899
|
-
confirmedAt: null,
|
|
7900
|
-
confirmedBy: null,
|
|
7901
|
-
recipientCount: null,
|
|
7902
|
-
stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
|
|
7903
|
-
createdAt: now,
|
|
7904
|
-
createdBy: req.actor,
|
|
7905
|
-
updatedAt: now
|
|
7906
|
-
});
|
|
7907
|
-
} catch (err) {
|
|
7908
|
-
if (err?.code === 11e3) return res.status(409).json({ error: "slug_taken" });
|
|
7909
|
-
throw err;
|
|
7910
|
-
}
|
|
7911
|
-
await mailer.audit({
|
|
7912
|
-
actor: req.actor,
|
|
7913
|
-
action: "broadcast.create",
|
|
7914
|
-
resource: { collection: "mailer_broadcasts", slug }
|
|
7915
|
-
});
|
|
8982
|
+
broadcastHandler(async (req, res) => {
|
|
8983
|
+
const { slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
|
|
8984
|
+
await createBroadcast(
|
|
8985
|
+
mailer,
|
|
8986
|
+
{ slug, name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
|
|
8987
|
+
req.actor
|
|
8988
|
+
);
|
|
7916
8989
|
return res.json({ ok: true, slug });
|
|
7917
8990
|
})
|
|
7918
8991
|
);
|
|
7919
8992
|
r.patch(
|
|
7920
8993
|
"/broadcasts/:slug",
|
|
7921
|
-
|
|
7922
|
-
const
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
if (segmentDefinition) set.segmentDefinition = segmentDefinition;
|
|
7930
|
-
await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
|
|
8994
|
+
broadcastHandler(async (req, res) => {
|
|
8995
|
+
const { name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order } = req.body ?? {};
|
|
8996
|
+
await patchBroadcast(
|
|
8997
|
+
mailer,
|
|
8998
|
+
String(req.params.slug),
|
|
8999
|
+
{ name, templateSlug, segmentDefinition, respectRecipientTimezone, recipientCap, order },
|
|
9000
|
+
req.actor
|
|
9001
|
+
);
|
|
7931
9002
|
return res.json({ ok: true });
|
|
7932
9003
|
})
|
|
7933
9004
|
);
|
|
7934
9005
|
r.post(
|
|
7935
9006
|
"/broadcasts/:slug/segment/count",
|
|
7936
|
-
|
|
7937
|
-
const
|
|
7938
|
-
if (!
|
|
7939
|
-
const
|
|
7940
|
-
const
|
|
7941
|
-
|
|
7942
|
-
if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
|
|
7943
|
-
if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
|
|
7944
|
-
}
|
|
7945
|
-
const hasMailerFilters = segmentDefinition.filters.some(
|
|
7946
|
-
(f) => ["subscriptionStatus", "firedEvent", "notFiredEvent", "notHasTag", "opened", "notOpened", "subscribedAfter", "subscribedBefore"].includes(f.kind)
|
|
7947
|
-
);
|
|
7948
|
-
const upperBound = await mailer.adapter.count(hostFilter);
|
|
9007
|
+
broadcastHandler(async (req, res) => {
|
|
9008
|
+
const raw = req.body?.segmentDefinition;
|
|
9009
|
+
if (!raw?.filters) return res.status(400).json({ error: "segment_required" });
|
|
9010
|
+
const segment = parseSegment(raw, false);
|
|
9011
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
9012
|
+
const count2 = await countRecipients(mailer, b, segment);
|
|
7949
9013
|
return res.json({
|
|
7950
|
-
upperBound,
|
|
7951
|
-
approximate:
|
|
7952
|
-
computedMs:
|
|
9014
|
+
upperBound: count2.recipientCount,
|
|
9015
|
+
approximate: false,
|
|
9016
|
+
computedMs: count2.computedMs,
|
|
9017
|
+
hostMatched: count2.hostMatched,
|
|
9018
|
+
eligible: count2.eligible,
|
|
9019
|
+
alreadySent: count2.alreadySent,
|
|
9020
|
+
recipientCount: count2.recipientCount
|
|
7953
9021
|
});
|
|
7954
9022
|
})
|
|
7955
9023
|
);
|
|
7956
9024
|
r.post(
|
|
7957
9025
|
"/broadcasts/:slug/schedule",
|
|
7958
|
-
|
|
7959
|
-
const b = await c.broadcasts.findOne({ slug: req.params.slug });
|
|
7960
|
-
if (!b) return res.status(404).json({ error: "not_found" });
|
|
7961
|
-
if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
|
|
9026
|
+
broadcastHandler(async (req, res) => {
|
|
7962
9027
|
const { scheduledAt, confirmedCount, respectRecipientTimezone } = req.body ?? {};
|
|
7963
|
-
|
|
7964
|
-
const scheduled = new Date(scheduledAt);
|
|
7965
|
-
if (Number.isNaN(scheduled.getTime())) return res.status(400).json({ error: "bad_scheduledAt" });
|
|
7966
|
-
const threshold = mailer.config.broadcastConfirmationThreshold;
|
|
7967
|
-
if (typeof confirmedCount !== "number") {
|
|
7968
|
-
return res.status(400).json({ error: "confirmedCount_required" });
|
|
7969
|
-
}
|
|
7970
|
-
const set = {
|
|
7971
|
-
status: "scheduled",
|
|
7972
|
-
scheduledAt: scheduled,
|
|
7973
|
-
confirmedCount,
|
|
7974
|
-
confirmedAt: /* @__PURE__ */ new Date(),
|
|
7975
|
-
confirmedBy: req.actor,
|
|
7976
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
7977
|
-
};
|
|
7978
|
-
if (respectRecipientTimezone) set.respectRecipientTimezone = true;
|
|
7979
|
-
await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
|
|
7980
|
-
await mailer.audit({
|
|
7981
|
-
actor: req.actor,
|
|
7982
|
-
action: "broadcast.schedule",
|
|
7983
|
-
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
7984
|
-
diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
|
|
7985
|
-
});
|
|
9028
|
+
await scheduleBroadcast(mailer, String(req.params.slug), { scheduledAt, confirmedCount, respectRecipientTimezone }, req.actor);
|
|
7986
9029
|
return res.json({ ok: true });
|
|
7987
9030
|
})
|
|
7988
9031
|
);
|
|
7989
9032
|
r.post(
|
|
7990
9033
|
"/broadcasts/:slug/cancel",
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
if (!b) return res.status(404).json({ error: "not_found" });
|
|
7994
|
-
await c.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
|
|
7995
|
-
await mailer.audit({
|
|
7996
|
-
actor: req.actor,
|
|
7997
|
-
action: "broadcast.cancel",
|
|
7998
|
-
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug }
|
|
7999
|
-
});
|
|
9034
|
+
broadcastHandler(async (req, res) => {
|
|
9035
|
+
await cancelBroadcast(mailer, String(req.params.slug), req.actor);
|
|
8000
9036
|
return res.json({ ok: true });
|
|
8001
9037
|
})
|
|
8002
9038
|
);
|
|
@@ -8133,35 +9169,15 @@ async function computeTemplateStats(mailer, slugFilter) {
|
|
|
8133
9169
|
}
|
|
8134
9170
|
return out;
|
|
8135
9171
|
}
|
|
8136
|
-
function
|
|
8137
|
-
return
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
const match = { broadcastId: { $ne: null } };
|
|
8142
|
-
if (idFilter) match.broadcastId = idFilter;
|
|
8143
|
-
const rows = await mailer.collections.sends.aggregate([
|
|
8144
|
-
{ $match: match },
|
|
8145
|
-
{
|
|
8146
|
-
$group: {
|
|
8147
|
-
_id: "$broadcastId",
|
|
8148
|
-
delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } },
|
|
8149
|
-
opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
|
|
8150
|
-
clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
|
|
8151
|
-
bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } }
|
|
9172
|
+
function broadcastHandler(fn) {
|
|
9173
|
+
return (req, res, next) => {
|
|
9174
|
+
fn(req, res, next).catch((err) => {
|
|
9175
|
+
if (err instanceof BroadcastOperationError) {
|
|
9176
|
+
return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
|
|
8152
9177
|
}
|
|
8153
|
-
|
|
8154
|
-
]).toArray();
|
|
8155
|
-
for (const row of rows) {
|
|
8156
|
-
if (!row._id) continue;
|
|
8157
|
-
out.set(String(row._id), {
|
|
8158
|
-
delivered: row.delivered,
|
|
8159
|
-
opened: row.opened,
|
|
8160
|
-
clicked: row.clicked,
|
|
8161
|
-
bounced: row.bounced
|
|
9178
|
+
next(err);
|
|
8162
9179
|
});
|
|
8163
|
-
}
|
|
8164
|
-
return out;
|
|
9180
|
+
};
|
|
8165
9181
|
}
|
|
8166
9182
|
|
|
8167
9183
|
// src/server/api/wrap.ts
|
|
@@ -8538,7 +9554,16 @@ function unitToMs2(value, unit) {
|
|
|
8538
9554
|
}
|
|
8539
9555
|
|
|
8540
9556
|
// src/server/api/agent.ts
|
|
8541
|
-
var VERSION = "0.
|
|
9557
|
+
var VERSION = "0.19.0" ;
|
|
9558
|
+
var broadcastCountSchema = z.object({
|
|
9559
|
+
recipientCap: z.number().int().positive().max(1e7).nullable().optional()
|
|
9560
|
+
});
|
|
9561
|
+
var broadcastTestSendSchema = z.object({
|
|
9562
|
+
contactIds: z.array(z.string().min(1).max(256)).min(1).max(10),
|
|
9563
|
+
vars: z.record(z.string(), z.unknown()).optional(),
|
|
9564
|
+
/** 'queue' leaves the sends queued for the worker instead of dispatching inline. */
|
|
9565
|
+
dispatch: z.enum(["now", "queue"]).optional()
|
|
9566
|
+
});
|
|
8542
9567
|
var agentTagsInputSchema = z.object({
|
|
8543
9568
|
add: z.array(z.string().min(1).max(128)).max(25).default([]),
|
|
8544
9569
|
remove: z.array(z.string().min(1).max(128)).max(25).default([])
|
|
@@ -8803,7 +9828,7 @@ function createAgentRouter(mailer, opts) {
|
|
|
8803
9828
|
}
|
|
8804
9829
|
const now = /* @__PURE__ */ new Date();
|
|
8805
9830
|
const publishedBy = input.publishedBy ?? actorOf(req);
|
|
8806
|
-
const
|
|
9831
|
+
const set2 = {
|
|
8807
9832
|
slug,
|
|
8808
9833
|
name: input.name,
|
|
8809
9834
|
description: input.description,
|
|
@@ -8834,7 +9859,7 @@ function createAgentRouter(mailer, opts) {
|
|
|
8834
9859
|
const result = await c.templates.updateOne(
|
|
8835
9860
|
{ slug },
|
|
8836
9861
|
{
|
|
8837
|
-
$set:
|
|
9862
|
+
$set: set2,
|
|
8838
9863
|
$setOnInsert: {
|
|
8839
9864
|
createdAt: now,
|
|
8840
9865
|
stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null }
|
|
@@ -9216,6 +10241,152 @@ function createAgentRouter(mailer, opts) {
|
|
|
9216
10241
|
res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
|
|
9217
10242
|
})
|
|
9218
10243
|
);
|
|
10244
|
+
router.get(
|
|
10245
|
+
"/broadcasts",
|
|
10246
|
+
wrap2(async (_req, res) => {
|
|
10247
|
+
const docs = await c.broadcasts.find().sort({ createdAt: -1 }).limit(200).toArray();
|
|
10248
|
+
const stats = await computeBroadcastStats(mailer);
|
|
10249
|
+
res.json(docs.map((b) => ({ ...broadcastSummary(b), stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
|
|
10250
|
+
})
|
|
10251
|
+
);
|
|
10252
|
+
router.get(
|
|
10253
|
+
"/broadcasts/:slug",
|
|
10254
|
+
wrap2(async (req, res) => {
|
|
10255
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
10256
|
+
const [statsMap, statusBreakdown] = await Promise.all([
|
|
10257
|
+
computeBroadcastStats(mailer, b._id),
|
|
10258
|
+
broadcastStatusBreakdown(mailer, b._id)
|
|
10259
|
+
]);
|
|
10260
|
+
const stats = statsMap.get(String(b._id)) ?? emptyBroadcastStats();
|
|
10261
|
+
res.json({
|
|
10262
|
+
broadcast: broadcastSummary(b),
|
|
10263
|
+
stats,
|
|
10264
|
+
statusBreakdown,
|
|
10265
|
+
capProgress: capProgress(b, stats),
|
|
10266
|
+
stopRules: stopRuleStatus(mailer, b, stats)
|
|
10267
|
+
});
|
|
10268
|
+
})
|
|
10269
|
+
);
|
|
10270
|
+
router.post(
|
|
10271
|
+
"/broadcasts",
|
|
10272
|
+
wrap2(async (req, res) => {
|
|
10273
|
+
const parsed = agentCreateBroadcastSchema.safeParse(req.body ?? {});
|
|
10274
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10275
|
+
const b = await createBroadcast(mailer, parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
|
|
10276
|
+
res.status(201).json({ broadcast: broadcastSummary(b) });
|
|
10277
|
+
})
|
|
10278
|
+
);
|
|
10279
|
+
router.patch(
|
|
10280
|
+
"/broadcasts/:slug",
|
|
10281
|
+
wrap2(async (req, res) => {
|
|
10282
|
+
const parsed = agentPatchBroadcastSchema.safeParse(req.body ?? {});
|
|
10283
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10284
|
+
const b = await patchBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), { requireSubscribed: true, strictSegment: true });
|
|
10285
|
+
res.json({ broadcast: broadcastSummary(b) });
|
|
10286
|
+
})
|
|
10287
|
+
);
|
|
10288
|
+
router.post(
|
|
10289
|
+
"/broadcasts/:slug/schedule",
|
|
10290
|
+
wrap2(async (req, res) => {
|
|
10291
|
+
const parsed = agentScheduleBroadcastSchema.safeParse(req.body ?? {});
|
|
10292
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10293
|
+
const b = await scheduleBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
|
|
10294
|
+
requireSubscribed: true,
|
|
10295
|
+
requireExactCount: true
|
|
10296
|
+
});
|
|
10297
|
+
res.json({ broadcast: broadcastSummary(b) });
|
|
10298
|
+
})
|
|
10299
|
+
);
|
|
10300
|
+
router.post(
|
|
10301
|
+
"/broadcasts/:slug/count",
|
|
10302
|
+
wrap2(async (req, res) => {
|
|
10303
|
+
const parsed = broadcastCountSchema.safeParse(req.body ?? {});
|
|
10304
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10305
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
10306
|
+
const count2 = await countRecipients(
|
|
10307
|
+
mailer,
|
|
10308
|
+
b,
|
|
10309
|
+
void 0,
|
|
10310
|
+
parsed.data.recipientCap !== void 0 ? { recipientCap: parsed.data.recipientCap } : {}
|
|
10311
|
+
);
|
|
10312
|
+
res.json({
|
|
10313
|
+
slug: b.slug,
|
|
10314
|
+
status: b.status,
|
|
10315
|
+
...count2,
|
|
10316
|
+
confirmationThreshold: mailer.config.broadcastConfirmationThreshold
|
|
10317
|
+
});
|
|
10318
|
+
})
|
|
10319
|
+
);
|
|
10320
|
+
router.post(
|
|
10321
|
+
"/broadcasts/:slug/test-send",
|
|
10322
|
+
wrap2(async (req, res) => {
|
|
10323
|
+
const parsed = broadcastTestSendSchema.safeParse(req.body ?? {});
|
|
10324
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10325
|
+
const b = await loadBroadcast(mailer, String(req.params.slug));
|
|
10326
|
+
const tpl = await loadBroadcastTemplate(mailer, b);
|
|
10327
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
10328
|
+
return res.status(409).json({ error: "not_published", message: `template "${tpl.slug}" has no published body` });
|
|
10329
|
+
}
|
|
10330
|
+
const contacts = [];
|
|
10331
|
+
for (const id of parsed.data.contactIds) {
|
|
10332
|
+
const contact = await loadContact(res, id);
|
|
10333
|
+
if (!contact) return;
|
|
10334
|
+
if (!guardTestContact(res, contact)) return;
|
|
10335
|
+
contacts.push(contact);
|
|
10336
|
+
}
|
|
10337
|
+
const ctx = mailer.getRunnerContext();
|
|
10338
|
+
const dispatchNow = parsed.data.dispatch !== "queue";
|
|
10339
|
+
const sends = [];
|
|
10340
|
+
for (const contact of contacts) {
|
|
10341
|
+
const { sendId } = await mailer.sendOneOff({
|
|
10342
|
+
templateSlug: tpl.slug,
|
|
10343
|
+
externalId: contact.externalId,
|
|
10344
|
+
dedupeKey: `broadcast-test:${b._id}:${contact.externalId}:${crypto2.randomUUID()}`,
|
|
10345
|
+
vars: parsed.data.vars
|
|
10346
|
+
});
|
|
10347
|
+
const _id = new ObjectId(sendId);
|
|
10348
|
+
await c.sends.updateOne({ _id }, { $set: { manualSendBy: `broadcast-test:${b.slug}` } });
|
|
10349
|
+
if (dispatchNow) await dispatchSend(_id, ctx);
|
|
10350
|
+
const row = await c.sends.findOne({ _id });
|
|
10351
|
+
if (row) sends.push(sendSummary(row));
|
|
10352
|
+
}
|
|
10353
|
+
await mailer.audit({
|
|
10354
|
+
actor: actorOf(req),
|
|
10355
|
+
action: "agent.broadcast.test-send",
|
|
10356
|
+
resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
|
|
10357
|
+
diffSummary: `template=${tpl.slug} to=${contacts.map((x) => x.email).join(", ")} dispatch=${dispatchNow ? "now" : "queue"}`
|
|
10358
|
+
});
|
|
10359
|
+
res.status(201).json({ broadcast: { slug: b.slug, status: b.status }, templateSlug: tpl.slug, dispatched: dispatchNow, sends });
|
|
10360
|
+
})
|
|
10361
|
+
);
|
|
10362
|
+
router.post(
|
|
10363
|
+
"/broadcasts/:slug/pause",
|
|
10364
|
+
wrap2(async (req, res) => {
|
|
10365
|
+
const parsed = agentPauseBroadcastSchema.safeParse(req.body ?? {});
|
|
10366
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10367
|
+
const out = await pauseBroadcastByOperator(mailer, String(req.params.slug), parsed.data, actorOf(req));
|
|
10368
|
+
res.json({ broadcast: broadcastSummary(out.broadcast), heldSends: out.heldSends });
|
|
10369
|
+
})
|
|
10370
|
+
);
|
|
10371
|
+
router.post(
|
|
10372
|
+
"/broadcasts/:slug/resume",
|
|
10373
|
+
wrap2(async (req, res) => {
|
|
10374
|
+
const parsed = agentResumeBroadcastSchema.safeParse(req.body ?? {});
|
|
10375
|
+
if (!parsed.success) return res.status(400).json({ error: "validation_failed", message: zodMessage(parsed.error) });
|
|
10376
|
+
const b = await resumeBroadcast(mailer, String(req.params.slug), parsed.data, actorOf(req), {
|
|
10377
|
+
requireSubscribed: true,
|
|
10378
|
+
requireExactCount: true
|
|
10379
|
+
});
|
|
10380
|
+
res.json({ broadcast: broadcastSummary(b) });
|
|
10381
|
+
})
|
|
10382
|
+
);
|
|
10383
|
+
router.post(
|
|
10384
|
+
"/broadcasts/:slug/cancel",
|
|
10385
|
+
wrap2(async (req, res) => {
|
|
10386
|
+
const out = await cancelBroadcast(mailer, String(req.params.slug), actorOf(req));
|
|
10387
|
+
res.json({ broadcast: broadcastSummary(out.broadcast), cancelledSends: out.cancelledSends });
|
|
10388
|
+
})
|
|
10389
|
+
);
|
|
9219
10390
|
router.post(
|
|
9220
10391
|
"/tick",
|
|
9221
10392
|
wrap2(async (_req, res) => {
|
|
@@ -9305,6 +10476,9 @@ function createAgentRouter(mailer, opts) {
|
|
|
9305
10476
|
if (err instanceof FlowOperationError) {
|
|
9306
10477
|
return res.status(err.status).json({ error: err.code, message: err.message });
|
|
9307
10478
|
}
|
|
10479
|
+
if (err instanceof BroadcastOperationError) {
|
|
10480
|
+
return res.status(err.status).json({ error: err.code, message: err.message, ...err.details ?? {} });
|
|
10481
|
+
}
|
|
9308
10482
|
if (err?.name === "ZodError") {
|
|
9309
10483
|
return res.status(400).json({ error: "validation_failed", issues: err.issues });
|
|
9310
10484
|
}
|
|
@@ -9673,6 +10847,9 @@ function runSummary(r) {
|
|
|
9673
10847
|
history: r.history
|
|
9674
10848
|
};
|
|
9675
10849
|
}
|
|
10850
|
+
function zodMessage(error) {
|
|
10851
|
+
return error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ");
|
|
10852
|
+
}
|
|
9676
10853
|
function objectOrUndefined(v) {
|
|
9677
10854
|
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
9678
10855
|
}
|
|
@@ -9711,6 +10888,16 @@ var ENDPOINTS = [
|
|
|
9711
10888
|
{ method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
|
|
9712
10889
|
{ method: "POST", path: "/contacts/:externalId/tags", summary: "Add or remove tags on a test contact ({add: [...], remove: [...]}), so a gated flow lets it through.", testContactsOnly: true },
|
|
9713
10890
|
{ 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 },
|
|
10891
|
+
{ method: "GET", path: "/broadcasts", summary: "Every broadcast (newest first, up to 200) with its stats." },
|
|
10892
|
+
{ 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." },
|
|
10893
|
+
{ method: "POST", path: "/broadcasts", summary: "Create a draft broadcast: {slug, name, templateSlug, segmentDefinition?, respectRecipientTimezone?}." },
|
|
10894
|
+
{ method: "PATCH", path: "/broadcasts/:slug", summary: "Edit a draft broadcast (name, templateSlug, segmentDefinition, respectRecipientTimezone). 409 once it has left draft." },
|
|
10895
|
+
{ 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." },
|
|
10896
|
+
{ 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." },
|
|
10897
|
+
{ 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 },
|
|
10898
|
+
{ method: "POST", path: "/broadcasts/:slug/pause", summary: "Pause a sending broadcast by hand ({reason?}); its queued sends are held." },
|
|
10899
|
+
{ 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." },
|
|
10900
|
+
{ method: "POST", path: "/broadcasts/:slug/cancel", summary: "Cancel a broadcast." },
|
|
9714
10901
|
{ method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
|
|
9715
10902
|
{ method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
|
|
9716
10903
|
{ method: "GET", path: "/status", summary: "One document with setup checks, health, every flow (enabled, version, watermark, gate, active runs), every template, and 24h counts." }
|
|
@@ -9951,12 +11138,15 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
9951
11138
|
return;
|
|
9952
11139
|
}
|
|
9953
11140
|
const now = /* @__PURE__ */ new Date();
|
|
11141
|
+
await mailer.collections.sends.updateOne(
|
|
11142
|
+
{ _id: sendId, status: "sent" },
|
|
11143
|
+
{ $set: { status: "delivered" } }
|
|
11144
|
+
);
|
|
9954
11145
|
await mailer.collections.sends.updateOne(
|
|
9955
11146
|
{ _id: sendId },
|
|
9956
11147
|
{
|
|
9957
11148
|
$set: {
|
|
9958
|
-
openedAt: send.openedAt ?? now
|
|
9959
|
-
status: "delivered"
|
|
11149
|
+
openedAt: send.openedAt ?? now
|
|
9960
11150
|
},
|
|
9961
11151
|
$inc: { openCount: 1 },
|
|
9962
11152
|
// Per-open user agent, so `hasOpenedExcludingBots` has a signal to
|
|
@@ -10095,6 +11285,11 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
10095
11285
|
}
|
|
10096
11286
|
}
|
|
10097
11287
|
res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
|
|
11288
|
+
if (!dbError && decoded.sendId) {
|
|
11289
|
+
void attributeUnsubscribeToSend(mailer.getRunnerContext(), decoded.sendId, decoded.email).catch((err) => {
|
|
11290
|
+
logger.warn?.({ err, sendId: decoded.sendId }, "mailery: unsubscribe attribution failed");
|
|
11291
|
+
});
|
|
11292
|
+
}
|
|
10098
11293
|
}));
|
|
10099
11294
|
router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
|
|
10100
11295
|
const token = req.params.token;
|
|
@@ -10426,7 +11621,7 @@ var DEDUPE_POLICIES = [
|
|
|
10426
11621
|
];
|
|
10427
11622
|
|
|
10428
11623
|
// src/server/index.ts
|
|
10429
|
-
var VERSION2 = "0.
|
|
11624
|
+
var VERSION2 = "0.19.0" ;
|
|
10430
11625
|
|
|
10431
11626
|
export { DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, FLOW_STEP_KINDS, FlowOperationError, MIN_AGENT_TOKEN_LENGTH, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, TRACKING_SIG_LENGTH, VERSION2 as VERSION, applyTracking, applyWebhookEvent, armFlow, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminApiRouter, createAdminRouter, createAgentRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, disarmFlow, dispatchSend, drainPendingUnsubscribes, ensureIndexes, gateFlow, getCollections, isCanaryGate, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, referencedPaths, renderForContact, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, simulateFlow, stampWatermarkIfNull, sweepStrandedFlowRuns, ungateFlow, validateSenderDomain, varsJsonSchema, verifyTemplate, verifyTrackingToken, verifyUnsubscribeToken };
|
|
10432
11627
|
//# sourceMappingURL=index.js.map
|