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/testing.cjs
CHANGED
|
@@ -134,6 +134,42 @@ __export(mongo_exports, {
|
|
|
134
134
|
function canBeObjectId(s) {
|
|
135
135
|
return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
|
|
136
136
|
}
|
|
137
|
+
function getPath(doc, path2) {
|
|
138
|
+
let cur = doc;
|
|
139
|
+
for (const part of path2.split(".")) {
|
|
140
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
141
|
+
cur = cur[part];
|
|
142
|
+
}
|
|
143
|
+
return cur;
|
|
144
|
+
}
|
|
145
|
+
function isObjectIdLike(v) {
|
|
146
|
+
return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
|
|
147
|
+
}
|
|
148
|
+
function packScalar(v) {
|
|
149
|
+
if (v instanceof Date) return { d: v.toISOString() };
|
|
150
|
+
if (isObjectIdLike(v)) return { o: v.toHexString() };
|
|
151
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
function unpackScalar(v) {
|
|
155
|
+
if (v && typeof v === "object") {
|
|
156
|
+
if ("d" in v) return new Date(v.d);
|
|
157
|
+
if ("o" in v) return new mongodb.ObjectId(v.o);
|
|
158
|
+
}
|
|
159
|
+
return v;
|
|
160
|
+
}
|
|
161
|
+
function encodeSortCursor(value, id) {
|
|
162
|
+
return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
|
|
163
|
+
}
|
|
164
|
+
function decodeSortCursor(cursor) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
167
|
+
if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
|
|
168
|
+
return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
137
173
|
var MongoContactAdapter;
|
|
138
174
|
var init_mongo = __esm({
|
|
139
175
|
"src/server/adapters/mongo.ts"() {
|
|
@@ -183,7 +219,10 @@ var init_mongo = __esm({
|
|
|
183
219
|
}
|
|
184
220
|
return out;
|
|
185
221
|
}
|
|
222
|
+
/** `query` honours `opts.sort` — see `querySorted`. */
|
|
223
|
+
supportsSort = true;
|
|
186
224
|
async query(filter, opts) {
|
|
225
|
+
if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
|
|
187
226
|
const query = this.translateFilterFn(filter);
|
|
188
227
|
const limit = Math.min(opts.limit, this.batchSize);
|
|
189
228
|
if (opts.cursor) {
|
|
@@ -202,6 +241,52 @@ var init_mongo = __esm({
|
|
|
202
241
|
const query = this.translateFilterFn(filter);
|
|
203
242
|
return await this.col.countDocuments(query);
|
|
204
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Keyset pagination in (sort.field, idField) order. The cursor encodes the
|
|
246
|
+
* last row's sort value and id, and the next page is every row strictly
|
|
247
|
+
* after that position. Mongo sorts a null or missing value lowest, so
|
|
248
|
+
* `desc` puts contacts without the field last and `asc` puts them first;
|
|
249
|
+
* the position predicate follows the same rule, so they are neither
|
|
250
|
+
* skipped nor repeated.
|
|
251
|
+
*
|
|
252
|
+
* The field should hold one BSON type (or be absent): range operators only
|
|
253
|
+
* compare within a type. A value that moves while a pass is paging (a
|
|
254
|
+
* contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
|
|
255
|
+
* at all in that pass; broadcast dispatch tolerates both (the per-recipient
|
|
256
|
+
* dedupe key stops a second send, and a later pass picks up a skipped one).
|
|
257
|
+
* Index `{ <field>: -1, <idField>: 1 }` on a large collection.
|
|
258
|
+
*/
|
|
259
|
+
async querySorted(filter, limitIn, cursor, sort) {
|
|
260
|
+
const base = this.translateFilterFn(filter);
|
|
261
|
+
const limit = Math.min(limitIn, this.batchSize);
|
|
262
|
+
const dir = sort.direction === "desc" ? -1 : 1;
|
|
263
|
+
let query = base;
|
|
264
|
+
if (cursor) {
|
|
265
|
+
const pos = decodeSortCursor(cursor);
|
|
266
|
+
if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
|
|
267
|
+
query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
|
|
268
|
+
}
|
|
269
|
+
const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
|
|
270
|
+
const hasMore = docs.length > limit;
|
|
271
|
+
const slice = hasMore ? docs.slice(0, limit) : docs;
|
|
272
|
+
const last = slice[slice.length - 1];
|
|
273
|
+
return {
|
|
274
|
+
contacts: slice.map((d) => this.toContactFn(d)),
|
|
275
|
+
nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
afterPosition(field, dir, value, id) {
|
|
279
|
+
const idF = this.idField;
|
|
280
|
+
const isNull = value === null || value === void 0;
|
|
281
|
+
if (dir === -1) {
|
|
282
|
+
if (isNull) return { [field]: null, [idF]: { $gt: id } };
|
|
283
|
+
return {
|
|
284
|
+
$or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
|
|
288
|
+
return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
|
|
289
|
+
}
|
|
205
290
|
addTags;
|
|
206
291
|
removeTags;
|
|
207
292
|
// -------------------------------------------------------------------------
|
|
@@ -1941,9 +2026,9 @@ https://typegoose.github.io/mongodb-memory-server/docs/guides/migration/migrate7
|
|
|
1941
2026
|
exports.AssertionFallbackError = AssertionFallbackError;
|
|
1942
2027
|
var ReplsetCountLowError = class extends Error {
|
|
1943
2028
|
count;
|
|
1944
|
-
constructor(
|
|
1945
|
-
super(`ReplSet Count needs to be 1 or higher! (specified count: "${
|
|
1946
|
-
this.count =
|
|
2029
|
+
constructor(count2) {
|
|
2030
|
+
super(`ReplSet Count needs to be 1 or higher! (specified count: "${count2}")`);
|
|
2031
|
+
this.count = count2;
|
|
1947
2032
|
}
|
|
1948
2033
|
};
|
|
1949
2034
|
exports.ReplsetCountLowError = ReplsetCountLowError;
|
|
@@ -3275,20 +3360,20 @@ var require_range = __commonJS({
|
|
|
3275
3360
|
}
|
|
3276
3361
|
return `${from} ${to}`.trim();
|
|
3277
3362
|
};
|
|
3278
|
-
var testSet = (
|
|
3279
|
-
for (let i = 0; i <
|
|
3280
|
-
if (!
|
|
3363
|
+
var testSet = (set2, version, options2) => {
|
|
3364
|
+
for (let i = 0; i < set2.length; i++) {
|
|
3365
|
+
if (!set2[i].test(version)) {
|
|
3281
3366
|
return false;
|
|
3282
3367
|
}
|
|
3283
3368
|
}
|
|
3284
3369
|
if (version.prerelease.length && !options2.includePrerelease) {
|
|
3285
|
-
for (let i = 0; i <
|
|
3286
|
-
debug(
|
|
3287
|
-
if (
|
|
3370
|
+
for (let i = 0; i < set2.length; i++) {
|
|
3371
|
+
debug(set2[i].semver);
|
|
3372
|
+
if (set2[i].semver === Comparator.ANY) {
|
|
3288
3373
|
continue;
|
|
3289
3374
|
}
|
|
3290
|
-
if (
|
|
3291
|
-
const allowed =
|
|
3375
|
+
if (set2[i].semver.prerelease.length > 0) {
|
|
3376
|
+
const allowed = set2[i].semver;
|
|
3292
3377
|
if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
|
|
3293
3378
|
return true;
|
|
3294
3379
|
}
|
|
@@ -3672,7 +3757,7 @@ var require_simplify = __commonJS({
|
|
|
3672
3757
|
var satisfies = require_satisfies();
|
|
3673
3758
|
var compare = require_compare();
|
|
3674
3759
|
module.exports = (versions, range, options2) => {
|
|
3675
|
-
const
|
|
3760
|
+
const set2 = [];
|
|
3676
3761
|
let first = null;
|
|
3677
3762
|
let prev = null;
|
|
3678
3763
|
const v = versions.sort((a, b) => compare(a, b, options2));
|
|
@@ -3685,17 +3770,17 @@ var require_simplify = __commonJS({
|
|
|
3685
3770
|
}
|
|
3686
3771
|
} else {
|
|
3687
3772
|
if (prev) {
|
|
3688
|
-
|
|
3773
|
+
set2.push([first, prev]);
|
|
3689
3774
|
}
|
|
3690
3775
|
prev = null;
|
|
3691
3776
|
first = null;
|
|
3692
3777
|
}
|
|
3693
3778
|
}
|
|
3694
3779
|
if (first) {
|
|
3695
|
-
|
|
3780
|
+
set2.push([first, null]);
|
|
3696
3781
|
}
|
|
3697
3782
|
const ranges = [];
|
|
3698
|
-
for (const [min, max] of
|
|
3783
|
+
for (const [min, max] of set2) {
|
|
3699
3784
|
if (min === max) {
|
|
3700
3785
|
ranges.push(min);
|
|
3701
3786
|
} else if (!max && min === v[0]) {
|
|
@@ -10672,18 +10757,18 @@ var require_semver3 = __commonJS({
|
|
|
10672
10757
|
range = range.replace(safeRe[t.CARETTRIM], caretTrimReplace);
|
|
10673
10758
|
range = range.split(/\s+/).join(" ");
|
|
10674
10759
|
var compRe = loose ? safeRe[t.COMPARATORLOOSE] : safeRe[t.COMPARATOR];
|
|
10675
|
-
var
|
|
10760
|
+
var set2 = range.split(" ").map(function(comp) {
|
|
10676
10761
|
return parseComparator(comp, this.options);
|
|
10677
10762
|
}, this).join(" ").split(/\s+/);
|
|
10678
10763
|
if (this.options.loose) {
|
|
10679
|
-
|
|
10764
|
+
set2 = set2.filter(function(comp) {
|
|
10680
10765
|
return !!comp.match(compRe);
|
|
10681
10766
|
});
|
|
10682
10767
|
}
|
|
10683
|
-
|
|
10768
|
+
set2 = set2.map(function(comp) {
|
|
10684
10769
|
return new Comparator(comp, this.options);
|
|
10685
10770
|
}, this);
|
|
10686
|
-
return
|
|
10771
|
+
return set2;
|
|
10687
10772
|
};
|
|
10688
10773
|
Range.prototype.intersects = function(range, options2) {
|
|
10689
10774
|
if (!(range instanceof Range)) {
|
|
@@ -10911,20 +10996,20 @@ var require_semver3 = __commonJS({
|
|
|
10911
10996
|
}
|
|
10912
10997
|
return false;
|
|
10913
10998
|
};
|
|
10914
|
-
function testSet(
|
|
10915
|
-
for (var i2 = 0; i2 <
|
|
10916
|
-
if (!
|
|
10999
|
+
function testSet(set2, version, options2) {
|
|
11000
|
+
for (var i2 = 0; i2 < set2.length; i2++) {
|
|
11001
|
+
if (!set2[i2].test(version)) {
|
|
10917
11002
|
return false;
|
|
10918
11003
|
}
|
|
10919
11004
|
}
|
|
10920
11005
|
if (version.prerelease.length && !options2.includePrerelease) {
|
|
10921
|
-
for (i2 = 0; i2 <
|
|
10922
|
-
debug(
|
|
10923
|
-
if (
|
|
11006
|
+
for (i2 = 0; i2 < set2.length; i2++) {
|
|
11007
|
+
debug(set2[i2].semver);
|
|
11008
|
+
if (set2[i2].semver === ANY) {
|
|
10924
11009
|
continue;
|
|
10925
11010
|
}
|
|
10926
|
-
if (
|
|
10927
|
-
var allowed =
|
|
11011
|
+
if (set2[i2].semver.prerelease.length > 0) {
|
|
11012
|
+
var allowed = set2[i2].semver;
|
|
10928
11013
|
if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
|
|
10929
11014
|
return true;
|
|
10930
11015
|
}
|
|
@@ -14343,6 +14428,8 @@ var sendOneOffInputSchema = zod.z.object({
|
|
|
14343
14428
|
providerOverride: zod.z.string().optional(),
|
|
14344
14429
|
dedupeKey: zod.z.string().min(1).max(512)
|
|
14345
14430
|
});
|
|
14431
|
+
zod.z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
|
|
14432
|
+
zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()]);
|
|
14346
14433
|
var flowStepSchema = zod.z.lazy(
|
|
14347
14434
|
() => zod.z.discriminatedUnion("type", [
|
|
14348
14435
|
zod.z.object({
|
|
@@ -14511,6 +14598,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
|
|
|
14511
14598
|
windowMinutes: 60,
|
|
14512
14599
|
minSendsBeforeEval: 100
|
|
14513
14600
|
};
|
|
14601
|
+
var BROADCAST_STOP_RULE_DEFAULTS = {
|
|
14602
|
+
enabled: true,
|
|
14603
|
+
hardBounceRatePct: 2,
|
|
14604
|
+
complaintRatePct: 0.1,
|
|
14605
|
+
unsubscribeRatePct: 1,
|
|
14606
|
+
minSample: 100
|
|
14607
|
+
};
|
|
14514
14608
|
function resolveConfig(c) {
|
|
14515
14609
|
return {
|
|
14516
14610
|
...c,
|
|
@@ -14538,7 +14632,8 @@ function resolveConfig(c) {
|
|
|
14538
14632
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
14539
14633
|
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
14540
14634
|
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
14541
|
-
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
14635
|
+
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
|
|
14636
|
+
broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
|
|
14542
14637
|
};
|
|
14543
14638
|
}
|
|
14544
14639
|
|
|
@@ -14756,11 +14851,13 @@ function resolveProvider(providers, name) {
|
|
|
14756
14851
|
function registeredProviderNames(providers) {
|
|
14757
14852
|
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
14758
14853
|
}
|
|
14854
|
+
var SEND_ID_RE = /^[a-f0-9]{24}$/i;
|
|
14759
14855
|
function signUnsubscribeToken(payload, secret) {
|
|
14760
14856
|
const body = JSON.stringify({
|
|
14761
14857
|
e: payload.email.toLowerCase(),
|
|
14762
14858
|
s: payload.scope,
|
|
14763
|
-
x: payload.expiresAt.getTime()
|
|
14859
|
+
x: payload.expiresAt.getTime(),
|
|
14860
|
+
...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
|
|
14764
14861
|
});
|
|
14765
14862
|
const bodyB64 = b64url(Buffer.from(body, "utf8"));
|
|
14766
14863
|
const hmac = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
|
|
@@ -15319,11 +15416,11 @@ var ZERO_COUNTERS = {
|
|
|
15319
15416
|
complained: 0,
|
|
15320
15417
|
failedToSend: 0
|
|
15321
15418
|
};
|
|
15322
|
-
var
|
|
15323
|
-
bounceRate:
|
|
15324
|
-
hardBounceRate:
|
|
15325
|
-
complaintRate:
|
|
15326
|
-
failureRate:
|
|
15419
|
+
var EMPTY_RATES = {
|
|
15420
|
+
bounceRate: null,
|
|
15421
|
+
hardBounceRate: null,
|
|
15422
|
+
complaintRate: null,
|
|
15423
|
+
failureRate: null
|
|
15327
15424
|
};
|
|
15328
15425
|
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15329
15426
|
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
@@ -15353,7 +15450,7 @@ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowM
|
|
|
15353
15450
|
trippedAt: null,
|
|
15354
15451
|
trippedReason: null,
|
|
15355
15452
|
manuallyResumedAt: null,
|
|
15356
|
-
rates: { ...
|
|
15453
|
+
rates: { ...EMPTY_RATES }
|
|
15357
15454
|
},
|
|
15358
15455
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15359
15456
|
},
|
|
@@ -15380,7 +15477,7 @@ async function evaluateHealth(ctx) {
|
|
|
15380
15477
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15381
15478
|
windowDurationMs: windowMs,
|
|
15382
15479
|
counters: { ...ZERO_COUNTERS },
|
|
15383
|
-
rates: { ...
|
|
15480
|
+
rates: { ...EMPTY_RATES },
|
|
15384
15481
|
status: "healthy",
|
|
15385
15482
|
updatedAt: /* @__PURE__ */ new Date()
|
|
15386
15483
|
}
|
|
@@ -15388,13 +15485,21 @@ async function evaluateHealth(ctx) {
|
|
|
15388
15485
|
);
|
|
15389
15486
|
continue;
|
|
15390
15487
|
}
|
|
15391
|
-
const c = doc.counters;
|
|
15392
|
-
const
|
|
15488
|
+
const c = { ...ZERO_COUNTERS, ...doc.counters };
|
|
15489
|
+
const perSent = (n) => c.sent > 0 ? n / c.sent : null;
|
|
15490
|
+
const attempts = c.sent + c.failedToSend;
|
|
15393
15491
|
const rates = {
|
|
15394
|
-
bounceRate: c.bounced
|
|
15395
|
-
hardBounceRate: c.hardBounced
|
|
15396
|
-
complaintRate: c.complained
|
|
15397
|
-
|
|
15492
|
+
bounceRate: perSent(c.bounced),
|
|
15493
|
+
hardBounceRate: perSent(c.hardBounced),
|
|
15494
|
+
complaintRate: perSent(c.complained),
|
|
15495
|
+
// Failed attempts over all attempts, so it stays within 0–100%.
|
|
15496
|
+
failureRate: attempts > 0 ? c.failedToSend / attempts : null
|
|
15497
|
+
};
|
|
15498
|
+
const pct2 = {
|
|
15499
|
+
bounce: (rates.bounceRate ?? 0) * 100,
|
|
15500
|
+
hardBounce: (rates.hardBounceRate ?? 0) * 100,
|
|
15501
|
+
complaint: (rates.complaintRate ?? 0) * 100,
|
|
15502
|
+
failure: (rates.failureRate ?? 0) * 100
|
|
15398
15503
|
};
|
|
15399
15504
|
await ctx.collections.health.updateOne(
|
|
15400
15505
|
{ _id: doc._id },
|
|
@@ -15404,12 +15509,12 @@ async function evaluateHealth(ctx) {
|
|
|
15404
15509
|
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15405
15510
|
if (doc.status === "tripped") continue;
|
|
15406
15511
|
let trippedReason = null;
|
|
15407
|
-
if (
|
|
15408
|
-
trippedReason = `hard bounce rate ${
|
|
15409
|
-
} else if (
|
|
15410
|
-
trippedReason = `complaint rate ${
|
|
15411
|
-
} else if (
|
|
15412
|
-
trippedReason = `combined bounce rate ${
|
|
15512
|
+
if (pct2.hardBounce >= cb.hardBounceRatePctTrip) {
|
|
15513
|
+
trippedReason = `hard bounce rate ${pct2.hardBounce.toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15514
|
+
} else if (pct2.complaint >= cb.complaintRatePctTrip) {
|
|
15515
|
+
trippedReason = `complaint rate ${pct2.complaint.toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15516
|
+
} else if (pct2.bounce >= cb.combinedBounceRatePctTrip) {
|
|
15517
|
+
trippedReason = `combined bounce rate ${pct2.bounce.toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15413
15518
|
}
|
|
15414
15519
|
if (trippedReason) {
|
|
15415
15520
|
const result = await ctx.collections.health.updateOne(
|
|
@@ -15444,7 +15549,7 @@ async function evaluateHealth(ctx) {
|
|
|
15444
15549
|
}
|
|
15445
15550
|
continue;
|
|
15446
15551
|
}
|
|
15447
|
-
if (
|
|
15552
|
+
if (pct2.failure >= cb.failedToSendRatePctDegrade) {
|
|
15448
15553
|
if (doc.status !== "degraded") {
|
|
15449
15554
|
await ctx.collections.health.updateOne(
|
|
15450
15555
|
{ _id: doc._id },
|
|
@@ -15464,6 +15569,179 @@ async function getBucketStatus(ctx, fromEmail, kind) {
|
|
|
15464
15569
|
const id = healthBucketId(domain, kind);
|
|
15465
15570
|
return ctx.collections.health.findOne({ _id: id });
|
|
15466
15571
|
}
|
|
15572
|
+
function emptyBroadcastStats() {
|
|
15573
|
+
return withRates({
|
|
15574
|
+
total: 0,
|
|
15575
|
+
accepted: 0,
|
|
15576
|
+
delivered: 0,
|
|
15577
|
+
bounced: 0,
|
|
15578
|
+
hardBounced: 0,
|
|
15579
|
+
softBounced: 0,
|
|
15580
|
+
complained: 0,
|
|
15581
|
+
unsubscribed: 0,
|
|
15582
|
+
opened: 0,
|
|
15583
|
+
clicked: 0,
|
|
15584
|
+
outcomes: 0
|
|
15585
|
+
});
|
|
15586
|
+
}
|
|
15587
|
+
var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
|
|
15588
|
+
function withRates(c) {
|
|
15589
|
+
return {
|
|
15590
|
+
...c,
|
|
15591
|
+
rates: {
|
|
15592
|
+
deliveryRatePct: pct(c.delivered, c.accepted),
|
|
15593
|
+
bounceRatePct: pct(c.bounced, c.outcomes),
|
|
15594
|
+
hardBounceRatePct: pct(c.hardBounced, c.outcomes),
|
|
15595
|
+
complaintRatePct: pct(c.complained, c.outcomes),
|
|
15596
|
+
unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
|
|
15597
|
+
openRatePct: pct(c.opened, c.delivered),
|
|
15598
|
+
clickRatePct: pct(c.clicked, c.delivered)
|
|
15599
|
+
}
|
|
15600
|
+
};
|
|
15601
|
+
}
|
|
15602
|
+
var set = (field) => ({ $ifNull: [field, false] });
|
|
15603
|
+
var DELIVERED = {
|
|
15604
|
+
$or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
|
|
15605
|
+
};
|
|
15606
|
+
var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
|
|
15607
|
+
var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
|
|
15608
|
+
async function aggregateBroadcastStats(collections, broadcastId) {
|
|
15609
|
+
const rows = await collections.sends.aggregate([
|
|
15610
|
+
{ $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
|
|
15611
|
+
{
|
|
15612
|
+
$group: {
|
|
15613
|
+
_id: "$broadcastId",
|
|
15614
|
+
total: { $sum: 1 },
|
|
15615
|
+
accepted: count(set("$sentAt")),
|
|
15616
|
+
delivered: count(DELIVERED),
|
|
15617
|
+
bounced: count(BOUNCED),
|
|
15618
|
+
hardBounced: count({ $eq: ["$bounceType", "hard"] }),
|
|
15619
|
+
softBounced: count({ $eq: ["$bounceType", "soft"] }),
|
|
15620
|
+
complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
|
|
15621
|
+
unsubscribed: count(set("$unsubscribedAt")),
|
|
15622
|
+
opened: count(set("$openedAt")),
|
|
15623
|
+
clicked: count(set("$firstClickAt")),
|
|
15624
|
+
outcomes: count({ $or: [DELIVERED, BOUNCED] })
|
|
15625
|
+
}
|
|
15626
|
+
}
|
|
15627
|
+
]).toArray();
|
|
15628
|
+
const out = /* @__PURE__ */ new Map();
|
|
15629
|
+
for (const { _id, ...c } of rows) {
|
|
15630
|
+
if (_id) out.set(String(_id), withRates(c));
|
|
15631
|
+
}
|
|
15632
|
+
return out;
|
|
15633
|
+
}
|
|
15634
|
+
async function broadcastStatsFor(collections, broadcastId) {
|
|
15635
|
+
return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
|
|
15636
|
+
}
|
|
15637
|
+
function effectiveStopRules(config, b) {
|
|
15638
|
+
return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
|
|
15639
|
+
}
|
|
15640
|
+
var RULE_COUNTS = [
|
|
15641
|
+
["complaintRatePct", "complained"],
|
|
15642
|
+
["hardBounceRatePct", "hardBounced"],
|
|
15643
|
+
["unsubscribeRatePct", "unsubscribed"]
|
|
15644
|
+
];
|
|
15645
|
+
function evaluateStopRules(stats, rules) {
|
|
15646
|
+
const sample = stats.outcomes;
|
|
15647
|
+
const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
|
|
15648
|
+
const breaches = [];
|
|
15649
|
+
if (evaluated) {
|
|
15650
|
+
for (const [rule, key] of RULE_COUNTS) {
|
|
15651
|
+
const n = stats[key];
|
|
15652
|
+
const exactPct = n / sample * 100;
|
|
15653
|
+
if (exactPct > rules[rule]) {
|
|
15654
|
+
breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
|
|
15655
|
+
}
|
|
15656
|
+
}
|
|
15657
|
+
}
|
|
15658
|
+
return { rules, sample, evaluated, breaches };
|
|
15659
|
+
}
|
|
15660
|
+
var RULE_LABEL = {
|
|
15661
|
+
complaintRatePct: "complaint rate",
|
|
15662
|
+
hardBounceRatePct: "hard bounce rate",
|
|
15663
|
+
unsubscribeRatePct: "unsubscribe rate"
|
|
15664
|
+
};
|
|
15665
|
+
var PAUSE_PRECEDENCE = {
|
|
15666
|
+
manual: 1,
|
|
15667
|
+
cap_reached: 1,
|
|
15668
|
+
stop_rule: 2,
|
|
15669
|
+
circuit_breaker: 2
|
|
15670
|
+
};
|
|
15671
|
+
async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
|
|
15672
|
+
const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
|
|
15673
|
+
(k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
|
|
15674
|
+
);
|
|
15675
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
15676
|
+
{
|
|
15677
|
+
_id: broadcastId,
|
|
15678
|
+
$or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
|
|
15679
|
+
},
|
|
15680
|
+
{ $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
|
|
15681
|
+
{ returnDocument: "after" }
|
|
15682
|
+
);
|
|
15683
|
+
if (!b) return { paused: false, heldSends: 0 };
|
|
15684
|
+
const held = await holdQueuedSends(ctx, broadcastId, reason.code);
|
|
15685
|
+
if (ctx.audit) {
|
|
15686
|
+
await ctx.audit({
|
|
15687
|
+
actor,
|
|
15688
|
+
action: "broadcast.pause",
|
|
15689
|
+
resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
|
|
15690
|
+
diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
|
|
15691
|
+
}).catch(() => {
|
|
15692
|
+
});
|
|
15693
|
+
}
|
|
15694
|
+
if (ctx.config.onBroadcastPaused) {
|
|
15695
|
+
try {
|
|
15696
|
+
await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
|
|
15697
|
+
} catch {
|
|
15698
|
+
}
|
|
15699
|
+
}
|
|
15700
|
+
return { paused: true, heldSends: held };
|
|
15701
|
+
}
|
|
15702
|
+
async function holdQueuedSends(ctx, broadcastId, code) {
|
|
15703
|
+
const res = await ctx.collections.sends.updateMany(
|
|
15704
|
+
{ broadcastId, status: "queued" },
|
|
15705
|
+
{ $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15706
|
+
);
|
|
15707
|
+
return res.modifiedCount;
|
|
15708
|
+
}
|
|
15709
|
+
async function evaluateBroadcastStopRules(ctx, broadcastId) {
|
|
15710
|
+
const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
|
|
15711
|
+
if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
|
|
15712
|
+
if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
|
|
15713
|
+
const stats = await broadcastStatsFor(ctx.collections, broadcastId);
|
|
15714
|
+
const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
|
|
15715
|
+
if (evaluation.breaches.length === 0) return evaluation;
|
|
15716
|
+
const now = /* @__PURE__ */ new Date();
|
|
15717
|
+
if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
|
|
15718
|
+
await ctx.collections.broadcasts.updateOne(
|
|
15719
|
+
{ _id: broadcastId, stopRuleBreach: { $in: [null] } },
|
|
15720
|
+
{ $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
|
|
15721
|
+
);
|
|
15722
|
+
return evaluation;
|
|
15723
|
+
}
|
|
15724
|
+
const first = evaluation.breaches[0];
|
|
15725
|
+
await pauseBroadcast(ctx, broadcastId, {
|
|
15726
|
+
code: "stop_rule",
|
|
15727
|
+
message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
|
|
15728
|
+
at: now,
|
|
15729
|
+
details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
|
|
15730
|
+
});
|
|
15731
|
+
return evaluation;
|
|
15732
|
+
}
|
|
15733
|
+
var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
15734
|
+
async function evaluateActiveBroadcastStopRules(ctx) {
|
|
15735
|
+
const live = await ctx.collections.broadcasts.find(
|
|
15736
|
+
{ status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
|
|
15737
|
+
{ projection: { _id: 1 } }
|
|
15738
|
+
).limit(200).toArray();
|
|
15739
|
+
for (const { _id } of live) {
|
|
15740
|
+
await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
|
|
15741
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
|
|
15742
|
+
});
|
|
15743
|
+
}
|
|
15744
|
+
}
|
|
15467
15745
|
|
|
15468
15746
|
// src/server/runner/webhook.ts
|
|
15469
15747
|
function dimsFromSend(send) {
|
|
@@ -15614,6 +15892,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
15614
15892
|
);
|
|
15615
15893
|
break;
|
|
15616
15894
|
}
|
|
15895
|
+
if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
|
|
15896
|
+
await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
|
|
15897
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
|
|
15898
|
+
});
|
|
15899
|
+
}
|
|
15617
15900
|
}
|
|
15618
15901
|
async function suppressOnce(ctx, email, reason, scope) {
|
|
15619
15902
|
const normalized = email.toLowerCase();
|
|
@@ -16011,11 +16294,30 @@ async function isSuppressed(collections, email, kind) {
|
|
|
16011
16294
|
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
16012
16295
|
const hashed = await collections.suppressions.findOne({
|
|
16013
16296
|
emailHash: sha256Hex(normalized),
|
|
16014
|
-
scope: { $in: allowed }
|
|
16297
|
+
scope: { $in: allowed },
|
|
16298
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
16015
16299
|
});
|
|
16016
16300
|
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
16017
16301
|
return { suppressed: false };
|
|
16018
16302
|
}
|
|
16303
|
+
async function suppressedEmails(collections, emails, kind) {
|
|
16304
|
+
const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
|
|
16305
|
+
if (normalized.length === 0) return /* @__PURE__ */ new Set();
|
|
16306
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
16307
|
+
const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
|
|
16308
|
+
const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
|
|
16309
|
+
const [plain, hashed] = await Promise.all([
|
|
16310
|
+
collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
|
|
16311
|
+
collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
|
|
16312
|
+
]);
|
|
16313
|
+
const out = /* @__PURE__ */ new Set();
|
|
16314
|
+
for (const e of plain) if (typeof e === "string") out.add(e);
|
|
16315
|
+
for (const h of hashed) {
|
|
16316
|
+
const e = byHash.get(String(h));
|
|
16317
|
+
if (e) out.add(e);
|
|
16318
|
+
}
|
|
16319
|
+
return out;
|
|
16320
|
+
}
|
|
16019
16321
|
|
|
16020
16322
|
// src/server/runner/send.ts
|
|
16021
16323
|
async function handleSend(run, step2, contact, flow, ctx) {
|
|
@@ -16092,6 +16394,27 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16092
16394
|
await markFailed(send._id, "template_missing", ctx);
|
|
16093
16395
|
return;
|
|
16094
16396
|
}
|
|
16397
|
+
if (send.broadcastId) {
|
|
16398
|
+
const broadcast = await ctx.collections.broadcasts.findOne(
|
|
16399
|
+
{ _id: send.broadcastId },
|
|
16400
|
+
{ projection: { status: 1, pauseReason: 1 } }
|
|
16401
|
+
);
|
|
16402
|
+
const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
|
|
16403
|
+
if (holding || broadcast?.status === "cancelled") {
|
|
16404
|
+
const held = holding;
|
|
16405
|
+
await ctx.collections.sends.updateOne(
|
|
16406
|
+
{ _id: send._id },
|
|
16407
|
+
{
|
|
16408
|
+
$set: {
|
|
16409
|
+
status: held ? "held" : "cancelled",
|
|
16410
|
+
errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
|
|
16411
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
16412
|
+
}
|
|
16413
|
+
}
|
|
16414
|
+
);
|
|
16415
|
+
return;
|
|
16416
|
+
}
|
|
16417
|
+
}
|
|
16095
16418
|
const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
|
|
16096
16419
|
if (supp.suppressed) {
|
|
16097
16420
|
await ctx.collections.sends.updateOne(
|
|
@@ -16102,6 +16425,19 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16102
16425
|
}
|
|
16103
16426
|
if (send.kind === "marketing") {
|
|
16104
16427
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
16428
|
+
if (bucket?.status === "tripped" && send.broadcastId) {
|
|
16429
|
+
await ctx.collections.sends.updateOne(
|
|
16430
|
+
{ _id: send._id },
|
|
16431
|
+
{ $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16432
|
+
);
|
|
16433
|
+
await pauseBroadcast(ctx, send.broadcastId, {
|
|
16434
|
+
code: "circuit_breaker",
|
|
16435
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
16436
|
+
at: /* @__PURE__ */ new Date(),
|
|
16437
|
+
details: { bucket: bucket._id }
|
|
16438
|
+
});
|
|
16439
|
+
return;
|
|
16440
|
+
}
|
|
16105
16441
|
if (bucket?.status === "tripped") {
|
|
16106
16442
|
await ctx.collections.sends.updateOne(
|
|
16107
16443
|
{ _id: send._id },
|
|
@@ -16116,6 +16452,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16116
16452
|
await markFailed(send._id, "contact_missing", ctx);
|
|
16117
16453
|
return;
|
|
16118
16454
|
}
|
|
16455
|
+
if (!send.emailAtSend?.trim() || !contact.email?.trim()) {
|
|
16456
|
+
await markFailed(send._id, "no_recipient: the contact has no email address", ctx);
|
|
16457
|
+
return;
|
|
16458
|
+
}
|
|
16119
16459
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
16120
16460
|
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
16121
16461
|
await ctx.collections.sends.updateOne(
|
|
@@ -16134,7 +16474,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16134
16474
|
eventName: run?.triggerEvent?.name,
|
|
16135
16475
|
eventProperties: run?.triggerEvent?.properties
|
|
16136
16476
|
});
|
|
16137
|
-
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
16477
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
|
|
16138
16478
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
16139
16479
|
} catch (err) {
|
|
16140
16480
|
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
@@ -16225,11 +16565,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
16225
16565
|
}
|
|
16226
16566
|
return ctx.config.defaultProvider;
|
|
16227
16567
|
}
|
|
16228
|
-
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
16568
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
|
|
16229
16569
|
const scope = "marketing";
|
|
16230
16570
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
16231
16571
|
const token = signUnsubscribeToken(
|
|
16232
|
-
{ email: contact.email, scope, expiresAt },
|
|
16572
|
+
{ email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
|
|
16233
16573
|
ctx.config.unsubscribeSecret
|
|
16234
16574
|
);
|
|
16235
16575
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
@@ -16312,6 +16652,10 @@ async function processOneRunStep(runId, ctx) {
|
|
|
16312
16652
|
await exitFlowRun(run, "contact_missing", ctx);
|
|
16313
16653
|
return;
|
|
16314
16654
|
}
|
|
16655
|
+
if (!contact.email?.trim()) {
|
|
16656
|
+
await exitFlowRun(run, "contact_no_email", ctx);
|
|
16657
|
+
return;
|
|
16658
|
+
}
|
|
16315
16659
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
|
|
16316
16660
|
if (sub && sub.status !== "subscribed") {
|
|
16317
16661
|
await exitFlowRun(run, sub.status, ctx);
|
|
@@ -16605,6 +16949,184 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16605
16949
|
}
|
|
16606
16950
|
}
|
|
16607
16951
|
}
|
|
16952
|
+
|
|
16953
|
+
// src/server/runner/segment.ts
|
|
16954
|
+
function planSegment(seg) {
|
|
16955
|
+
const hostFilter = {};
|
|
16956
|
+
const postFilters = [];
|
|
16957
|
+
const hostFields = /* @__PURE__ */ new Set();
|
|
16958
|
+
const takeField = (field) => {
|
|
16959
|
+
if (hostFields.has(field)) return false;
|
|
16960
|
+
hostFields.add(field);
|
|
16961
|
+
return true;
|
|
16962
|
+
};
|
|
16963
|
+
for (const f of seg.filters ?? []) {
|
|
16964
|
+
switch (f.kind) {
|
|
16965
|
+
case "hasTag":
|
|
16966
|
+
if (hostFilter.hasTag === void 0) {
|
|
16967
|
+
hostFilter.hasTag = f.tag;
|
|
16968
|
+
continue;
|
|
16969
|
+
}
|
|
16970
|
+
break;
|
|
16971
|
+
case "fieldEquals":
|
|
16972
|
+
if (!hostFilter.fieldEquals && takeField(f.field)) {
|
|
16973
|
+
hostFilter.fieldEquals = { field: f.field, value: f.value };
|
|
16974
|
+
continue;
|
|
16975
|
+
}
|
|
16976
|
+
break;
|
|
16977
|
+
case "fieldIn":
|
|
16978
|
+
if (!hostFilter.fieldIn && takeField(f.field)) {
|
|
16979
|
+
hostFilter.fieldIn = { field: f.field, values: f.values };
|
|
16980
|
+
continue;
|
|
16981
|
+
}
|
|
16982
|
+
break;
|
|
16983
|
+
case "fieldExists":
|
|
16984
|
+
if (!hostFilter.fieldExists && takeField(f.field)) {
|
|
16985
|
+
hostFilter.fieldExists = f.field;
|
|
16986
|
+
continue;
|
|
16987
|
+
}
|
|
16988
|
+
break;
|
|
16989
|
+
}
|
|
16990
|
+
postFilters.push(f);
|
|
16991
|
+
}
|
|
16992
|
+
return { hostFilter, postFilters };
|
|
16993
|
+
}
|
|
16994
|
+
async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
|
|
16995
|
+
if (filters.length === 0 || contacts.length === 0) return contacts;
|
|
16996
|
+
const externalIds = contacts.map((c) => c.externalId);
|
|
16997
|
+
const leaves = /* @__PURE__ */ new Map();
|
|
16998
|
+
collectLookups(filters, leaves);
|
|
16999
|
+
const cache = /* @__PURE__ */ new Map();
|
|
17000
|
+
await Promise.all(
|
|
17001
|
+
[...leaves].map(async ([key, f]) => {
|
|
17002
|
+
cache.set(key, await lookup(f, externalIds, ctx, now));
|
|
17003
|
+
})
|
|
17004
|
+
);
|
|
17005
|
+
return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
|
|
17006
|
+
}
|
|
17007
|
+
function lookupKey(f) {
|
|
17008
|
+
switch (f.kind) {
|
|
17009
|
+
case "subscriptionStatus":
|
|
17010
|
+
return `sub:${f.equals}`;
|
|
17011
|
+
case "firedEvent":
|
|
17012
|
+
case "notFiredEvent":
|
|
17013
|
+
return `evt:${f.withinDays ?? ""}:${f.eventName}`;
|
|
17014
|
+
case "opened":
|
|
17015
|
+
case "notOpened":
|
|
17016
|
+
return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
|
|
17017
|
+
case "subscribedAfter":
|
|
17018
|
+
return `subAfter:${toDate(f.date).getTime()}`;
|
|
17019
|
+
case "subscribedBefore":
|
|
17020
|
+
return `subBefore:${toDate(f.date).getTime()}`;
|
|
17021
|
+
default:
|
|
17022
|
+
return null;
|
|
17023
|
+
}
|
|
17024
|
+
}
|
|
17025
|
+
function collectLookups(filters, out) {
|
|
17026
|
+
for (const f of filters) {
|
|
17027
|
+
if (f.kind === "any") collectLookups(f.filters, out);
|
|
17028
|
+
else if (f.kind === "not") collectLookups([f.filter], out);
|
|
17029
|
+
else {
|
|
17030
|
+
const key = lookupKey(f);
|
|
17031
|
+
if (key && !out.has(key)) out.set(key, f);
|
|
17032
|
+
}
|
|
17033
|
+
}
|
|
17034
|
+
}
|
|
17035
|
+
async function lookup(f, externalIds, ctx, now) {
|
|
17036
|
+
const c = ctx.collections;
|
|
17037
|
+
const inPage = { $in: externalIds };
|
|
17038
|
+
const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
|
|
17039
|
+
let ids;
|
|
17040
|
+
switch (f.kind) {
|
|
17041
|
+
case "subscriptionStatus":
|
|
17042
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
|
|
17043
|
+
break;
|
|
17044
|
+
case "firedEvent":
|
|
17045
|
+
case "notFiredEvent": {
|
|
17046
|
+
const cutoff = since(f.withinDays);
|
|
17047
|
+
ids = await c.events.distinct("externalId", {
|
|
17048
|
+
externalId: inPage,
|
|
17049
|
+
name: f.eventName,
|
|
17050
|
+
...cutoff ? { occurredAt: { $gt: cutoff } } : {}
|
|
17051
|
+
});
|
|
17052
|
+
break;
|
|
17053
|
+
}
|
|
17054
|
+
case "opened":
|
|
17055
|
+
case "notOpened": {
|
|
17056
|
+
const cutoff = since(f.withinDays);
|
|
17057
|
+
ids = await c.sends.distinct("externalId", {
|
|
17058
|
+
externalId: inPage,
|
|
17059
|
+
openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
|
|
17060
|
+
...f.templateSlug ? { templateSlug: f.templateSlug } : {}
|
|
17061
|
+
});
|
|
17062
|
+
break;
|
|
17063
|
+
}
|
|
17064
|
+
case "subscribedAfter":
|
|
17065
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
|
|
17066
|
+
break;
|
|
17067
|
+
case "subscribedBefore":
|
|
17068
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
|
|
17069
|
+
break;
|
|
17070
|
+
default:
|
|
17071
|
+
ids = [];
|
|
17072
|
+
}
|
|
17073
|
+
return new Set(ids.map(String));
|
|
17074
|
+
}
|
|
17075
|
+
function matches(c, f, cache) {
|
|
17076
|
+
switch (f.kind) {
|
|
17077
|
+
case "subscriptionStatus":
|
|
17078
|
+
case "firedEvent":
|
|
17079
|
+
case "opened":
|
|
17080
|
+
case "subscribedAfter":
|
|
17081
|
+
case "subscribedBefore":
|
|
17082
|
+
return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
|
|
17083
|
+
case "notFiredEvent":
|
|
17084
|
+
case "notOpened":
|
|
17085
|
+
return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
|
|
17086
|
+
case "hasTag":
|
|
17087
|
+
return c.tags.includes(f.tag);
|
|
17088
|
+
case "notHasTag":
|
|
17089
|
+
return !c.tags.includes(f.tag);
|
|
17090
|
+
case "fieldEquals":
|
|
17091
|
+
return valuesEqual(fieldValue(c, f.field), f.value);
|
|
17092
|
+
case "fieldIn": {
|
|
17093
|
+
const v = fieldValue(c, f.field);
|
|
17094
|
+
return f.values.some((x) => valuesEqual(v, x));
|
|
17095
|
+
}
|
|
17096
|
+
case "fieldExists":
|
|
17097
|
+
return fieldValue(c, f.field) !== void 0;
|
|
17098
|
+
case "any":
|
|
17099
|
+
return f.filters.some((sub) => matches(c, sub, cache));
|
|
17100
|
+
case "not":
|
|
17101
|
+
return !matches(c, f.filter, cache);
|
|
17102
|
+
default:
|
|
17103
|
+
throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
|
|
17104
|
+
}
|
|
17105
|
+
}
|
|
17106
|
+
function fieldValue(c, path2) {
|
|
17107
|
+
let cur = c.fields;
|
|
17108
|
+
for (const part of path2.split(".")) {
|
|
17109
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
17110
|
+
cur = cur[part];
|
|
17111
|
+
}
|
|
17112
|
+
return cur;
|
|
17113
|
+
}
|
|
17114
|
+
function valuesEqual(a, b) {
|
|
17115
|
+
if (a instanceof Date || b instanceof Date) {
|
|
17116
|
+
const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
|
|
17117
|
+
const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
|
|
17118
|
+
return !Number.isNaN(ta) && ta === tb;
|
|
17119
|
+
}
|
|
17120
|
+
if (a && typeof a === "object" && typeof a.toHexString === "function") {
|
|
17121
|
+
return String(a) === String(b);
|
|
17122
|
+
}
|
|
17123
|
+
return a === b;
|
|
17124
|
+
}
|
|
17125
|
+
function toDate(d) {
|
|
17126
|
+
return d instanceof Date ? d : new Date(d);
|
|
17127
|
+
}
|
|
17128
|
+
|
|
17129
|
+
// src/server/runner/broadcasts.ts
|
|
16608
17130
|
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16609
17131
|
async function processScheduledBroadcasts(ctx) {
|
|
16610
17132
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -16612,7 +17134,7 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16612
17134
|
for (const b of due) {
|
|
16613
17135
|
const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
16614
17136
|
{ _id: b._id, status: "scheduled" },
|
|
16615
|
-
{ $set: { status: "sending", startedAt: now, updatedAt: now } },
|
|
17137
|
+
{ $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
|
|
16616
17138
|
{ returnDocument: "after" }
|
|
16617
17139
|
);
|
|
16618
17140
|
if (!claimed) continue;
|
|
@@ -16620,8 +17142,14 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16620
17142
|
}
|
|
16621
17143
|
}
|
|
16622
17144
|
async function startBroadcastDispatch(broadcast, ctx) {
|
|
17145
|
+
const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
17146
|
+
{ _id: broadcast._id },
|
|
17147
|
+
{ $inc: { dispatchGeneration: 1 } },
|
|
17148
|
+
{ returnDocument: "after" }
|
|
17149
|
+
);
|
|
17150
|
+
const current = bumped ?? broadcast;
|
|
16623
17151
|
if (ctx.config.queue.driver === "noop") {
|
|
16624
|
-
await runBroadcastDispatch(
|
|
17152
|
+
await runBroadcastDispatch(current, ctx);
|
|
16625
17153
|
return;
|
|
16626
17154
|
}
|
|
16627
17155
|
await ctx.queues.advance.add(
|
|
@@ -16630,7 +17158,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
|
|
|
16630
17158
|
{
|
|
16631
17159
|
attempts: 3,
|
|
16632
17160
|
backoff: { type: "exponential", delay: 6e4 },
|
|
16633
|
-
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
17161
|
+
jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
|
|
16634
17162
|
}
|
|
16635
17163
|
);
|
|
16636
17164
|
}
|
|
@@ -16643,10 +17171,11 @@ async function resumeStalledBroadcasts(ctx) {
|
|
|
16643
17171
|
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16644
17172
|
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16645
17173
|
for (const b of stalled) {
|
|
16646
|
-
await ctx.collections.broadcasts.updateOne(
|
|
16647
|
-
{ _id: b._id },
|
|
16648
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
17174
|
+
const reset = await ctx.collections.broadcasts.updateOne(
|
|
17175
|
+
{ _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
17176
|
+
{ $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
|
|
16649
17177
|
);
|
|
17178
|
+
if (reset.modifiedCount === 0) continue;
|
|
16650
17179
|
await startBroadcastDispatch(b, ctx);
|
|
16651
17180
|
}
|
|
16652
17181
|
}
|
|
@@ -16656,50 +17185,117 @@ async function runBroadcastDispatch(broadcast, ctx) {
|
|
|
16656
17185
|
} catch (err) {
|
|
16657
17186
|
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16658
17187
|
await ctx.collections.broadcasts.updateOne(
|
|
16659
|
-
{ _id: broadcast._id },
|
|
16660
|
-
{
|
|
17188
|
+
{ _id: broadcast._id, status: "sending" },
|
|
17189
|
+
{
|
|
17190
|
+
$set: {
|
|
17191
|
+
status: "failed",
|
|
17192
|
+
failureReason: `dispatch error: ${String(err?.message ?? err)}`,
|
|
17193
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17194
|
+
dispatchLeaseId: null
|
|
17195
|
+
}
|
|
17196
|
+
}
|
|
16661
17197
|
);
|
|
16662
17198
|
}
|
|
16663
17199
|
}
|
|
17200
|
+
function broadcastDedupeKey(broadcastId, externalId) {
|
|
17201
|
+
return `broadcast:${broadcastId}:${externalId}`;
|
|
17202
|
+
}
|
|
17203
|
+
var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
17204
|
+
function isSendableEmail(email) {
|
|
17205
|
+
return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
|
|
17206
|
+
}
|
|
17207
|
+
async function* eligibleRecipientPages(broadcast, kind, ctx) {
|
|
17208
|
+
const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
|
|
17209
|
+
const sort = broadcast.order ?? void 0;
|
|
17210
|
+
if (sort && !ctx.adapter.supportsSort) {
|
|
17211
|
+
throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
|
|
17212
|
+
}
|
|
17213
|
+
let cursor;
|
|
17214
|
+
for (; ; ) {
|
|
17215
|
+
const page = await ctx.adapter.query(hostFilter, {
|
|
17216
|
+
limit: ctx.config.broadcastEnqueueBatchSize,
|
|
17217
|
+
cursor,
|
|
17218
|
+
...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
|
|
17219
|
+
});
|
|
17220
|
+
if (page.contacts.length === 0) break;
|
|
17221
|
+
const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
|
|
17222
|
+
const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
|
|
17223
|
+
yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
|
|
17224
|
+
if (!page.nextCursor) break;
|
|
17225
|
+
cursor = page.nextCursor;
|
|
17226
|
+
}
|
|
17227
|
+
}
|
|
17228
|
+
async function alreadyDispatched(ctx, broadcastId, contacts) {
|
|
17229
|
+
if (contacts.length === 0) return /* @__PURE__ */ new Set();
|
|
17230
|
+
const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
|
|
17231
|
+
const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
|
|
17232
|
+
return new Set(found.map(String));
|
|
17233
|
+
}
|
|
16664
17234
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
16665
17235
|
const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
|
|
16666
|
-
|
|
17236
|
+
const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
|
|
17237
|
+
if (failure || !template) {
|
|
16667
17238
|
await ctx.collections.broadcasts.updateOne(
|
|
16668
|
-
{ _id: broadcast._id },
|
|
16669
|
-
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17239
|
+
{ _id: broadcast._id, status: "sending" },
|
|
17240
|
+
{ $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
|
|
16670
17241
|
);
|
|
16671
17242
|
return;
|
|
16672
17243
|
}
|
|
16673
|
-
const
|
|
16674
|
-
const
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
|
|
17244
|
+
const leaseId = new mongodb.ObjectId().toHexString();
|
|
17245
|
+
const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
17246
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
17247
|
+
{
|
|
17248
|
+
_id: broadcast._id,
|
|
17249
|
+
status: "sending",
|
|
17250
|
+
$or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
|
|
17251
|
+
},
|
|
17252
|
+
{ $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
|
|
17253
|
+
{ returnDocument: "after" }
|
|
17254
|
+
);
|
|
17255
|
+
if (!b) return;
|
|
17256
|
+
const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
|
|
17257
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
17258
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
17259
|
+
)).matchedCount === 1;
|
|
16678
17260
|
const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
|
|
16679
|
-
const respectTimezone =
|
|
16680
|
-
const scheduledMs =
|
|
16681
|
-
|
|
16682
|
-
|
|
16683
|
-
|
|
16684
|
-
|
|
16685
|
-
|
|
16686
|
-
|
|
16687
|
-
|
|
16688
|
-
|
|
16689
|
-
|
|
17261
|
+
const respectTimezone = b.respectRecipientTimezone === true;
|
|
17262
|
+
const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
|
|
17263
|
+
const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
|
|
17264
|
+
let capReached = false;
|
|
17265
|
+
try {
|
|
17266
|
+
for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
|
|
17267
|
+
if (!await holdsLease()) return;
|
|
17268
|
+
const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
|
|
17269
|
+
if (bucket?.status === "tripped") {
|
|
17270
|
+
await pauseBroadcast(ctx, b._id, {
|
|
17271
|
+
code: "circuit_breaker",
|
|
17272
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
17273
|
+
at: /* @__PURE__ */ new Date(),
|
|
17274
|
+
details: { bucket: bucket._id }
|
|
17275
|
+
});
|
|
17276
|
+
return;
|
|
17277
|
+
}
|
|
17278
|
+
const seen = await alreadyDispatched(ctx, b._id, eligible);
|
|
17279
|
+
let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
|
|
17280
|
+
if (fresh.length === 0) continue;
|
|
17281
|
+
if (cap !== null) {
|
|
17282
|
+
const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
17283
|
+
if (room <= 0) {
|
|
17284
|
+
capReached = true;
|
|
17285
|
+
break;
|
|
17286
|
+
}
|
|
17287
|
+
if (fresh.length > room) {
|
|
17288
|
+
fresh = fresh.slice(0, room);
|
|
17289
|
+
capReached = true;
|
|
17290
|
+
}
|
|
17291
|
+
}
|
|
16690
17292
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16691
|
-
await
|
|
16692
|
-
{ _id: broadcast._id },
|
|
16693
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16694
|
-
);
|
|
17293
|
+
if (!await holdsLease()) return;
|
|
16695
17294
|
await sleep(2e3);
|
|
16696
17295
|
}
|
|
16697
|
-
const sendDocs = await Promise.all(
|
|
16698
|
-
eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
|
|
16699
|
-
);
|
|
16700
17296
|
const inserted = [];
|
|
16701
|
-
for (const
|
|
16702
|
-
|
|
17297
|
+
for (const contact of fresh) {
|
|
17298
|
+
const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
|
|
16703
17299
|
try {
|
|
16704
17300
|
await ctx.collections.sends.insertOne(doc);
|
|
16705
17301
|
inserted.push({ sendId: doc._id, delayMs });
|
|
@@ -16707,115 +17303,51 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16707
17303
|
if (err?.code !== 11e3) throw err;
|
|
16708
17304
|
}
|
|
16709
17305
|
}
|
|
16710
|
-
|
|
16711
|
-
|
|
16712
|
-
|
|
16713
|
-
|
|
16714
|
-
|
|
16715
|
-
|
|
16716
|
-
|
|
16717
|
-
|
|
16718
|
-
|
|
16719
|
-
|
|
16720
|
-
}
|
|
16721
|
-
)
|
|
17306
|
+
await Promise.all(
|
|
17307
|
+
inserted.map(
|
|
17308
|
+
({ sendId, delayMs }) => ctx.queues.send.add(
|
|
17309
|
+
"send",
|
|
17310
|
+
{ sendId: String(sendId) },
|
|
17311
|
+
{
|
|
17312
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
17313
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
17314
|
+
...delayMs > 0 ? { delay: delayMs } : {}
|
|
17315
|
+
}
|
|
16722
17316
|
)
|
|
16723
|
-
)
|
|
16724
|
-
|
|
16725
|
-
|
|
16726
|
-
}
|
|
16727
|
-
if (!page.nextCursor) break;
|
|
16728
|
-
cursor = page.nextCursor;
|
|
16729
|
-
}
|
|
16730
|
-
await ctx.collections.broadcasts.updateOne(
|
|
16731
|
-
{ _id: broadcast._id },
|
|
16732
|
-
{
|
|
16733
|
-
$set: {
|
|
16734
|
-
status: "sent",
|
|
16735
|
-
completedAt: /* @__PURE__ */ new Date(),
|
|
16736
|
-
recipientCount: total,
|
|
16737
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
16738
|
-
}
|
|
16739
|
-
}
|
|
16740
|
-
);
|
|
16741
|
-
}
|
|
16742
|
-
function toAdapterFilter(seg) {
|
|
16743
|
-
const out = {};
|
|
16744
|
-
for (const f of seg.filters) {
|
|
16745
|
-
switch (f.kind) {
|
|
16746
|
-
case "hasTag":
|
|
16747
|
-
out.hasTag = f.tag;
|
|
16748
|
-
break;
|
|
16749
|
-
case "fieldEquals":
|
|
16750
|
-
out.fieldEquals = { field: f.field, value: f.value };
|
|
16751
|
-
break;
|
|
16752
|
-
case "fieldIn":
|
|
16753
|
-
out.fieldIn = { field: f.field, values: f.values };
|
|
16754
|
-
break;
|
|
16755
|
-
case "fieldExists":
|
|
16756
|
-
out.fieldExists = f.field;
|
|
16757
|
-
break;
|
|
16758
|
-
}
|
|
16759
|
-
}
|
|
16760
|
-
return out;
|
|
16761
|
-
}
|
|
16762
|
-
function isMailerSide(f) {
|
|
16763
|
-
return f.kind === "subscriptionStatus" || f.kind === "firedEvent" || f.kind === "notFiredEvent" || f.kind === "subscribedAfter" || f.kind === "subscribedBefore" || f.kind === "opened" || f.kind === "notOpened" || f.kind === "notHasTag" || f.kind === "any" || f.kind === "not";
|
|
16764
|
-
}
|
|
16765
|
-
async function applyPostFilters(contacts, filters, ctx) {
|
|
16766
|
-
if (filters.length === 0) return contacts;
|
|
16767
|
-
const externalIds = contacts.map((c) => c.externalId);
|
|
16768
|
-
const cache = {};
|
|
16769
|
-
for (const f of filters) {
|
|
16770
|
-
if (f.kind === "subscriptionStatus") {
|
|
16771
|
-
const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
|
|
16772
|
-
cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
|
|
16773
|
-
}
|
|
16774
|
-
if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
|
|
16775
|
-
const query = { externalId: { $in: externalIds }, name: f.eventName };
|
|
16776
|
-
if (f.withinDays) {
|
|
16777
|
-
query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
|
|
16778
|
-
}
|
|
16779
|
-
const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
|
|
16780
|
-
cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
|
|
17317
|
+
)
|
|
17318
|
+
);
|
|
17319
|
+
if (capReached) break;
|
|
16781
17320
|
}
|
|
16782
|
-
|
|
16783
|
-
|
|
16784
|
-
|
|
16785
|
-
|
|
16786
|
-
|
|
16787
|
-
|
|
16788
|
-
|
|
16789
|
-
|
|
16790
|
-
|
|
16791
|
-
|
|
16792
|
-
|
|
16793
|
-
|
|
16794
|
-
|
|
16795
|
-
|
|
16796
|
-
|
|
16797
|
-
|
|
16798
|
-
|
|
16799
|
-
|
|
16800
|
-
|
|
16801
|
-
|
|
16802
|
-
|
|
16803
|
-
return f.filters.some((sub) => filterMatches(c, sub, cache));
|
|
16804
|
-
case "not":
|
|
16805
|
-
return !filterMatches(c, f.filter, cache);
|
|
16806
|
-
default:
|
|
16807
|
-
return true;
|
|
17321
|
+
const now = /* @__PURE__ */ new Date();
|
|
17322
|
+
const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
17323
|
+
const set2 = capReached ? {
|
|
17324
|
+
status: "paused",
|
|
17325
|
+
pausedAt: now,
|
|
17326
|
+
pauseReason: {
|
|
17327
|
+
code: "cap_reached",
|
|
17328
|
+
message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
|
|
17329
|
+
at: now,
|
|
17330
|
+
details: { recipientCap: cap, sendsSoFar: total }
|
|
17331
|
+
}
|
|
17332
|
+
} : { status: "sent", completedAt: now };
|
|
17333
|
+
await ctx.collections.broadcasts.updateOne(
|
|
17334
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
17335
|
+
{ $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
|
|
17336
|
+
);
|
|
17337
|
+
} finally {
|
|
17338
|
+
await ctx.collections.broadcasts.updateOne(
|
|
17339
|
+
{ _id: b._id, dispatchLeaseId: leaseId },
|
|
17340
|
+
{ $set: { dispatchLeaseId: null } }
|
|
17341
|
+
);
|
|
16808
17342
|
}
|
|
16809
17343
|
}
|
|
16810
|
-
|
|
16811
|
-
const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
|
|
16812
|
-
if (supp.suppressed) return { doc: null, delayMs: 0 };
|
|
17344
|
+
function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
|
|
16813
17345
|
const sendId = new mongodb.ObjectId();
|
|
16814
|
-
const dedupeKey =
|
|
16815
|
-
|
|
17346
|
+
const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
|
|
17347
|
+
const now = Date.now();
|
|
17348
|
+
let delayMs = Math.max(0, scheduledMs - now);
|
|
16816
17349
|
if (respectTimezone && contact.timezone) {
|
|
16817
|
-
|
|
16818
|
-
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
17350
|
+
delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
|
|
16819
17351
|
}
|
|
16820
17352
|
const doc = {
|
|
16821
17353
|
_id: sendId,
|
|
@@ -16850,16 +17382,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16850
17382
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
16851
17383
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
16852
17384
|
sentAt: null,
|
|
16853
|
-
deliveredAt: null
|
|
17385
|
+
deliveredAt: null,
|
|
17386
|
+
notBefore: new Date(Date.now() + delayMs)
|
|
16854
17387
|
};
|
|
16855
17388
|
return { doc, delayMs };
|
|
16856
17389
|
}
|
|
17390
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
17391
|
+
var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
|
|
17392
|
+
function recipientSlotMs(scheduledMs, timezone, nowMs) {
|
|
17393
|
+
let anchor = scheduledMs;
|
|
17394
|
+
let target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
17395
|
+
const earliest = nowMs - TZ_SLOT_GRACE_MS;
|
|
17396
|
+
if (target < earliest) {
|
|
17397
|
+
anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
|
|
17398
|
+
target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
17399
|
+
if (target < earliest) target += DAY_MS;
|
|
17400
|
+
}
|
|
17401
|
+
return target;
|
|
17402
|
+
}
|
|
16857
17403
|
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16858
|
-
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16859
17404
|
try {
|
|
16860
17405
|
const scheduled = new Date(scheduledMs);
|
|
16861
|
-
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC",
|
|
16862
|
-
const local = scheduled.toLocaleString("en-US", { timeZone: timezone,
|
|
17406
|
+
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
|
|
17407
|
+
const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
|
|
16863
17408
|
const parse = (s) => {
|
|
16864
17409
|
const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
|
|
16865
17410
|
if (!m) return 0;
|
|
@@ -16979,10 +17524,10 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
16979
17524
|
}
|
|
16980
17525
|
let listedCount = 0;
|
|
16981
17526
|
async function processPair(p) {
|
|
16982
|
-
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
16983
|
-
const
|
|
16984
|
-
if (
|
|
16985
|
-
if (
|
|
17527
|
+
const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
|
|
17528
|
+
const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
17529
|
+
if (lookup2.transient) return;
|
|
17530
|
+
if (lookup2.result === "listed") listedCount++;
|
|
16986
17531
|
await ctx.collections.dnsblChecks.updateOne(
|
|
16987
17532
|
{ target: p.target, list: p.list.host },
|
|
16988
17533
|
{
|
|
@@ -16991,9 +17536,9 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
16991
17536
|
targetKind: p.targetKind,
|
|
16992
17537
|
list: p.list.host,
|
|
16993
17538
|
listLabel: p.list.label,
|
|
16994
|
-
result:
|
|
16995
|
-
returnCodes:
|
|
16996
|
-
errorMessage:
|
|
17539
|
+
result: lookup2.result,
|
|
17540
|
+
returnCodes: lookup2.returnCodes,
|
|
17541
|
+
errorMessage: lookup2.errorMessage,
|
|
16997
17542
|
runAt: /* @__PURE__ */ new Date()
|
|
16998
17543
|
}
|
|
16999
17544
|
},
|
|
@@ -17026,6 +17571,10 @@ function collectTargets(ctx, cfg) {
|
|
|
17026
17571
|
ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
|
|
17027
17572
|
};
|
|
17028
17573
|
}
|
|
17574
|
+
function spamhausQueryHost(listHost, dqsKey) {
|
|
17575
|
+
const zone = dqsKey ? /^([a-z0-9-]+)\.spamhaus\.org$/i.exec(listHost)?.[1] : void 0;
|
|
17576
|
+
return zone ? `${dqsKey}.${zone}.dq.spamhaus.net` : listHost;
|
|
17577
|
+
}
|
|
17029
17578
|
function buildQueryName(target, kind, listHost) {
|
|
17030
17579
|
if (kind === "ip") {
|
|
17031
17580
|
const v = net__default.default.isIP(target);
|
|
@@ -17061,16 +17610,22 @@ async function queryDnsbl(resolver, query) {
|
|
|
17061
17610
|
};
|
|
17062
17611
|
}
|
|
17063
17612
|
}
|
|
17613
|
+
var REFUSAL_REASONS = {
|
|
17614
|
+
"127.255.255.252": "malformed query name for this list",
|
|
17615
|
+
"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",
|
|
17616
|
+
"127.255.255.255": "this resolver has exceeded the free query limit"
|
|
17617
|
+
};
|
|
17064
17618
|
function interpretRecords(records) {
|
|
17065
17619
|
if (!records || records.length === 0) {
|
|
17066
17620
|
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
17067
17621
|
}
|
|
17068
17622
|
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
17069
17623
|
if (errorish.length === records.length) {
|
|
17624
|
+
const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
|
|
17070
17625
|
return {
|
|
17071
17626
|
result: "error",
|
|
17072
17627
|
returnCodes: records,
|
|
17073
|
-
errorMessage: `list
|
|
17628
|
+
errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
|
|
17074
17629
|
};
|
|
17075
17630
|
}
|
|
17076
17631
|
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
@@ -17834,6 +18389,9 @@ async function runTick(ctx) {
|
|
|
17834
18389
|
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17835
18390
|
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17836
18391
|
});
|
|
18392
|
+
await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
|
|
18393
|
+
console.error("mailery: broadcast stop-rule sweep failed", err);
|
|
18394
|
+
});
|
|
17837
18395
|
await evaluateHealth(ctx).catch((err) => {
|
|
17838
18396
|
console.error("mailery: health evaluation failed", err);
|
|
17839
18397
|
});
|
|
@@ -18538,6 +19096,25 @@ var Mailer = class _Mailer {
|
|
|
18538
19096
|
};
|
|
18539
19097
|
|
|
18540
19098
|
// src/testing/memory-adapter.ts
|
|
19099
|
+
function fieldPath(c, path2) {
|
|
19100
|
+
let cur = c.fields;
|
|
19101
|
+
for (const part of path2.split(".")) {
|
|
19102
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
19103
|
+
cur = cur[part];
|
|
19104
|
+
}
|
|
19105
|
+
return cur;
|
|
19106
|
+
}
|
|
19107
|
+
function compareValues(a, b) {
|
|
19108
|
+
const an = a === null || a === void 0;
|
|
19109
|
+
const bn = b === null || b === void 0;
|
|
19110
|
+
if (an || bn) return an && bn ? 0 : an ? -1 : 1;
|
|
19111
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
19112
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
19113
|
+
if (typeof av === "number" && typeof bv === "number") return av - bv;
|
|
19114
|
+
const as = String(av);
|
|
19115
|
+
const bs = String(bv);
|
|
19116
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
19117
|
+
}
|
|
18541
19118
|
var MemoryContactAdapter = class {
|
|
18542
19119
|
byId = /* @__PURE__ */ new Map();
|
|
18543
19120
|
constructor(initial = []) {
|
|
@@ -18572,8 +19149,29 @@ var MemoryContactAdapter = class {
|
|
|
18572
19149
|
}
|
|
18573
19150
|
return out;
|
|
18574
19151
|
}
|
|
19152
|
+
/** `query` honours `opts.sort` over `contact.fields` (dotted paths), like MongoContactAdapter over documents. */
|
|
19153
|
+
supportsSort = true;
|
|
18575
19154
|
async query(filter, opts) {
|
|
18576
|
-
|
|
19155
|
+
if (opts.sort) {
|
|
19156
|
+
const { field, direction } = opts.sort;
|
|
19157
|
+
const dir = direction === "desc" ? -1 : 1;
|
|
19158
|
+
const sorted = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => {
|
|
19159
|
+
const byValue = compareValues(fieldPath(a, field), fieldPath(b, field)) * dir;
|
|
19160
|
+
if (byValue !== 0) return byValue;
|
|
19161
|
+
return a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0;
|
|
19162
|
+
});
|
|
19163
|
+
let start = 0;
|
|
19164
|
+
if (opts.cursor) {
|
|
19165
|
+
const at = sorted.findIndex((c) => c.externalId === opts.cursor);
|
|
19166
|
+
start = at === -1 ? sorted.length : at + 1;
|
|
19167
|
+
}
|
|
19168
|
+
const page = sorted.slice(start, start + opts.limit);
|
|
19169
|
+
return {
|
|
19170
|
+
contacts: page,
|
|
19171
|
+
nextCursor: start + opts.limit < sorted.length ? page[page.length - 1]?.externalId : void 0
|
|
19172
|
+
};
|
|
19173
|
+
}
|
|
19174
|
+
const all = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => a.externalId < b.externalId ? -1 : 1);
|
|
18577
19175
|
const startIndex = opts.cursor ? all.findIndex((c) => c.externalId > opts.cursor) : 0;
|
|
18578
19176
|
const slice = all.slice(startIndex === -1 ? all.length : startIndex, (startIndex === -1 ? all.length : startIndex) + opts.limit);
|
|
18579
19177
|
const hasMore = startIndex !== -1 && startIndex + opts.limit < all.length;
|
|
@@ -18584,15 +19182,15 @@ var MemoryContactAdapter = class {
|
|
|
18584
19182
|
}
|
|
18585
19183
|
async count(filter) {
|
|
18586
19184
|
let n = 0;
|
|
18587
|
-
for (const c of this.byId.values()) if (
|
|
19185
|
+
for (const c of this.byId.values()) if (matches2(c, filter)) n++;
|
|
18588
19186
|
return n;
|
|
18589
19187
|
}
|
|
18590
19188
|
async addTags(externalId, tags) {
|
|
18591
19189
|
const c = this.byId.get(externalId);
|
|
18592
19190
|
if (!c) return;
|
|
18593
|
-
const
|
|
18594
|
-
for (const t of tags)
|
|
18595
|
-
c.tags = Array.from(
|
|
19191
|
+
const set2 = new Set(c.tags);
|
|
19192
|
+
for (const t of tags) set2.add(t);
|
|
19193
|
+
c.tags = Array.from(set2);
|
|
18596
19194
|
}
|
|
18597
19195
|
async removeTags(externalId, tags) {
|
|
18598
19196
|
const c = this.byId.get(externalId);
|
|
@@ -18601,7 +19199,7 @@ var MemoryContactAdapter = class {
|
|
|
18601
19199
|
c.tags = c.tags.filter((t) => !remove.has(t));
|
|
18602
19200
|
}
|
|
18603
19201
|
};
|
|
18604
|
-
function
|
|
19202
|
+
function matches2(c, f) {
|
|
18605
19203
|
if (f.emailIn && !f.emailIn.map((e) => e.toLowerCase()).includes(c.email)) return false;
|
|
18606
19204
|
if (f.externalIdIn && !f.externalIdIn.includes(c.externalId)) return false;
|
|
18607
19205
|
if (f.fieldEquals && c.fields[f.fieldEquals.field] !== f.fieldEquals.value) return false;
|