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.js
CHANGED
|
@@ -119,6 +119,42 @@ __export(mongo_exports, {
|
|
|
119
119
|
function canBeObjectId(s) {
|
|
120
120
|
return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
|
|
121
121
|
}
|
|
122
|
+
function getPath(doc, path2) {
|
|
123
|
+
let cur = doc;
|
|
124
|
+
for (const part of path2.split(".")) {
|
|
125
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
126
|
+
cur = cur[part];
|
|
127
|
+
}
|
|
128
|
+
return cur;
|
|
129
|
+
}
|
|
130
|
+
function isObjectIdLike(v) {
|
|
131
|
+
return !!v && typeof v === "object" && typeof v.toHexString === "function" && v._bsontype === "ObjectId";
|
|
132
|
+
}
|
|
133
|
+
function packScalar(v) {
|
|
134
|
+
if (v instanceof Date) return { d: v.toISOString() };
|
|
135
|
+
if (isObjectIdLike(v)) return { o: v.toHexString() };
|
|
136
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
function unpackScalar(v) {
|
|
140
|
+
if (v && typeof v === "object") {
|
|
141
|
+
if ("d" in v) return new Date(v.d);
|
|
142
|
+
if ("o" in v) return new ObjectId(v.o);
|
|
143
|
+
}
|
|
144
|
+
return v;
|
|
145
|
+
}
|
|
146
|
+
function encodeSortCursor(value, id) {
|
|
147
|
+
return Buffer.from(JSON.stringify({ v: packScalar(value), i: packScalar(id) }), "utf8").toString("base64url");
|
|
148
|
+
}
|
|
149
|
+
function decodeSortCursor(cursor) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
152
|
+
if (!parsed || typeof parsed !== "object" || !("v" in parsed) || !("i" in parsed)) return null;
|
|
153
|
+
return { value: unpackScalar(parsed.v), id: unpackScalar(parsed.i) };
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
122
158
|
var MongoContactAdapter;
|
|
123
159
|
var init_mongo = __esm({
|
|
124
160
|
"src/server/adapters/mongo.ts"() {
|
|
@@ -168,7 +204,10 @@ var init_mongo = __esm({
|
|
|
168
204
|
}
|
|
169
205
|
return out;
|
|
170
206
|
}
|
|
207
|
+
/** `query` honours `opts.sort` — see `querySorted`. */
|
|
208
|
+
supportsSort = true;
|
|
171
209
|
async query(filter, opts) {
|
|
210
|
+
if (opts.sort) return this.querySorted(filter, opts.limit, opts.cursor, opts.sort);
|
|
172
211
|
const query = this.translateFilterFn(filter);
|
|
173
212
|
const limit = Math.min(opts.limit, this.batchSize);
|
|
174
213
|
if (opts.cursor) {
|
|
@@ -187,6 +226,52 @@ var init_mongo = __esm({
|
|
|
187
226
|
const query = this.translateFilterFn(filter);
|
|
188
227
|
return await this.col.countDocuments(query);
|
|
189
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Keyset pagination in (sort.field, idField) order. The cursor encodes the
|
|
231
|
+
* last row's sort value and id, and the next page is every row strictly
|
|
232
|
+
* after that position. Mongo sorts a null or missing value lowest, so
|
|
233
|
+
* `desc` puts contacts without the field last and `asc` puts them first;
|
|
234
|
+
* the position predicate follows the same rule, so they are neither
|
|
235
|
+
* skipped nor repeated.
|
|
236
|
+
*
|
|
237
|
+
* The field should hold one BSON type (or be absent): range operators only
|
|
238
|
+
* compare within a type. A value that moves while a pass is paging (a
|
|
239
|
+
* contact whose `updatedAt` changes mid-dispatch) can be seen twice or not
|
|
240
|
+
* at all in that pass; broadcast dispatch tolerates both (the per-recipient
|
|
241
|
+
* dedupe key stops a second send, and a later pass picks up a skipped one).
|
|
242
|
+
* Index `{ <field>: -1, <idField>: 1 }` on a large collection.
|
|
243
|
+
*/
|
|
244
|
+
async querySorted(filter, limitIn, cursor, sort) {
|
|
245
|
+
const base = this.translateFilterFn(filter);
|
|
246
|
+
const limit = Math.min(limitIn, this.batchSize);
|
|
247
|
+
const dir = sort.direction === "desc" ? -1 : 1;
|
|
248
|
+
let query = base;
|
|
249
|
+
if (cursor) {
|
|
250
|
+
const pos = decodeSortCursor(cursor);
|
|
251
|
+
if (!pos) throw new Error("MongoContactAdapter: malformed sort cursor");
|
|
252
|
+
query = { $and: [base, this.afterPosition(sort.field, dir, pos.value, pos.id)] };
|
|
253
|
+
}
|
|
254
|
+
const docs = await this.col.find(query).sort({ [sort.field]: dir, [this.idField]: 1 }).limit(limit + 1).toArray();
|
|
255
|
+
const hasMore = docs.length > limit;
|
|
256
|
+
const slice = hasMore ? docs.slice(0, limit) : docs;
|
|
257
|
+
const last = slice[slice.length - 1];
|
|
258
|
+
return {
|
|
259
|
+
contacts: slice.map((d) => this.toContactFn(d)),
|
|
260
|
+
nextCursor: hasMore && last ? encodeSortCursor(getPath(last, sort.field), last[this.idField]) : void 0
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
afterPosition(field, dir, value, id) {
|
|
264
|
+
const idF = this.idField;
|
|
265
|
+
const isNull = value === null || value === void 0;
|
|
266
|
+
if (dir === -1) {
|
|
267
|
+
if (isNull) return { [field]: null, [idF]: { $gt: id } };
|
|
268
|
+
return {
|
|
269
|
+
$or: [{ [field]: { $lt: value } }, { [field]: value, [idF]: { $gt: id } }, { [field]: null }]
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (isNull) return { $or: [{ [field]: null, [idF]: { $gt: id } }, { [field]: { $ne: null } }] };
|
|
273
|
+
return { $or: [{ [field]: { $gt: value } }, { [field]: value, [idF]: { $gt: id } }] };
|
|
274
|
+
}
|
|
190
275
|
addTags;
|
|
191
276
|
removeTags;
|
|
192
277
|
// -------------------------------------------------------------------------
|
|
@@ -1926,9 +2011,9 @@ https://typegoose.github.io/mongodb-memory-server/docs/guides/migration/migrate7
|
|
|
1926
2011
|
exports.AssertionFallbackError = AssertionFallbackError;
|
|
1927
2012
|
var ReplsetCountLowError = class extends Error {
|
|
1928
2013
|
count;
|
|
1929
|
-
constructor(
|
|
1930
|
-
super(`ReplSet Count needs to be 1 or higher! (specified count: "${
|
|
1931
|
-
this.count =
|
|
2014
|
+
constructor(count2) {
|
|
2015
|
+
super(`ReplSet Count needs to be 1 or higher! (specified count: "${count2}")`);
|
|
2016
|
+
this.count = count2;
|
|
1932
2017
|
}
|
|
1933
2018
|
};
|
|
1934
2019
|
exports.ReplsetCountLowError = ReplsetCountLowError;
|
|
@@ -3260,20 +3345,20 @@ var require_range = __commonJS({
|
|
|
3260
3345
|
}
|
|
3261
3346
|
return `${from} ${to}`.trim();
|
|
3262
3347
|
};
|
|
3263
|
-
var testSet = (
|
|
3264
|
-
for (let i = 0; i <
|
|
3265
|
-
if (!
|
|
3348
|
+
var testSet = (set2, version, options2) => {
|
|
3349
|
+
for (let i = 0; i < set2.length; i++) {
|
|
3350
|
+
if (!set2[i].test(version)) {
|
|
3266
3351
|
return false;
|
|
3267
3352
|
}
|
|
3268
3353
|
}
|
|
3269
3354
|
if (version.prerelease.length && !options2.includePrerelease) {
|
|
3270
|
-
for (let i = 0; i <
|
|
3271
|
-
debug(
|
|
3272
|
-
if (
|
|
3355
|
+
for (let i = 0; i < set2.length; i++) {
|
|
3356
|
+
debug(set2[i].semver);
|
|
3357
|
+
if (set2[i].semver === Comparator.ANY) {
|
|
3273
3358
|
continue;
|
|
3274
3359
|
}
|
|
3275
|
-
if (
|
|
3276
|
-
const allowed =
|
|
3360
|
+
if (set2[i].semver.prerelease.length > 0) {
|
|
3361
|
+
const allowed = set2[i].semver;
|
|
3277
3362
|
if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
|
|
3278
3363
|
return true;
|
|
3279
3364
|
}
|
|
@@ -3657,7 +3742,7 @@ var require_simplify = __commonJS({
|
|
|
3657
3742
|
var satisfies = require_satisfies();
|
|
3658
3743
|
var compare = require_compare();
|
|
3659
3744
|
module.exports = (versions, range, options2) => {
|
|
3660
|
-
const
|
|
3745
|
+
const set2 = [];
|
|
3661
3746
|
let first = null;
|
|
3662
3747
|
let prev = null;
|
|
3663
3748
|
const v = versions.sort((a, b) => compare(a, b, options2));
|
|
@@ -3670,17 +3755,17 @@ var require_simplify = __commonJS({
|
|
|
3670
3755
|
}
|
|
3671
3756
|
} else {
|
|
3672
3757
|
if (prev) {
|
|
3673
|
-
|
|
3758
|
+
set2.push([first, prev]);
|
|
3674
3759
|
}
|
|
3675
3760
|
prev = null;
|
|
3676
3761
|
first = null;
|
|
3677
3762
|
}
|
|
3678
3763
|
}
|
|
3679
3764
|
if (first) {
|
|
3680
|
-
|
|
3765
|
+
set2.push([first, null]);
|
|
3681
3766
|
}
|
|
3682
3767
|
const ranges = [];
|
|
3683
|
-
for (const [min, max] of
|
|
3768
|
+
for (const [min, max] of set2) {
|
|
3684
3769
|
if (min === max) {
|
|
3685
3770
|
ranges.push(min);
|
|
3686
3771
|
} else if (!max && min === v[0]) {
|
|
@@ -10657,18 +10742,18 @@ var require_semver3 = __commonJS({
|
|
|
10657
10742
|
range = range.replace(safeRe[t.CARETTRIM], caretTrimReplace);
|
|
10658
10743
|
range = range.split(/\s+/).join(" ");
|
|
10659
10744
|
var compRe = loose ? safeRe[t.COMPARATORLOOSE] : safeRe[t.COMPARATOR];
|
|
10660
|
-
var
|
|
10745
|
+
var set2 = range.split(" ").map(function(comp) {
|
|
10661
10746
|
return parseComparator(comp, this.options);
|
|
10662
10747
|
}, this).join(" ").split(/\s+/);
|
|
10663
10748
|
if (this.options.loose) {
|
|
10664
|
-
|
|
10749
|
+
set2 = set2.filter(function(comp) {
|
|
10665
10750
|
return !!comp.match(compRe);
|
|
10666
10751
|
});
|
|
10667
10752
|
}
|
|
10668
|
-
|
|
10753
|
+
set2 = set2.map(function(comp) {
|
|
10669
10754
|
return new Comparator(comp, this.options);
|
|
10670
10755
|
}, this);
|
|
10671
|
-
return
|
|
10756
|
+
return set2;
|
|
10672
10757
|
};
|
|
10673
10758
|
Range.prototype.intersects = function(range, options2) {
|
|
10674
10759
|
if (!(range instanceof Range)) {
|
|
@@ -10896,20 +10981,20 @@ var require_semver3 = __commonJS({
|
|
|
10896
10981
|
}
|
|
10897
10982
|
return false;
|
|
10898
10983
|
};
|
|
10899
|
-
function testSet(
|
|
10900
|
-
for (var i2 = 0; i2 <
|
|
10901
|
-
if (!
|
|
10984
|
+
function testSet(set2, version, options2) {
|
|
10985
|
+
for (var i2 = 0; i2 < set2.length; i2++) {
|
|
10986
|
+
if (!set2[i2].test(version)) {
|
|
10902
10987
|
return false;
|
|
10903
10988
|
}
|
|
10904
10989
|
}
|
|
10905
10990
|
if (version.prerelease.length && !options2.includePrerelease) {
|
|
10906
|
-
for (i2 = 0; i2 <
|
|
10907
|
-
debug(
|
|
10908
|
-
if (
|
|
10991
|
+
for (i2 = 0; i2 < set2.length; i2++) {
|
|
10992
|
+
debug(set2[i2].semver);
|
|
10993
|
+
if (set2[i2].semver === ANY) {
|
|
10909
10994
|
continue;
|
|
10910
10995
|
}
|
|
10911
|
-
if (
|
|
10912
|
-
var allowed =
|
|
10996
|
+
if (set2[i2].semver.prerelease.length > 0) {
|
|
10997
|
+
var allowed = set2[i2].semver;
|
|
10913
10998
|
if (allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch) {
|
|
10914
10999
|
return true;
|
|
10915
11000
|
}
|
|
@@ -14328,6 +14413,8 @@ var sendOneOffInputSchema = z.object({
|
|
|
14328
14413
|
providerOverride: z.string().optional(),
|
|
14329
14414
|
dedupeKey: z.string().min(1).max(512)
|
|
14330
14415
|
});
|
|
14416
|
+
z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"]);
|
|
14417
|
+
z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
14331
14418
|
var flowStepSchema = z.lazy(
|
|
14332
14419
|
() => z.discriminatedUnion("type", [
|
|
14333
14420
|
z.object({
|
|
@@ -14496,6 +14583,13 @@ var CIRCUIT_BREAKER_DEFAULTS = {
|
|
|
14496
14583
|
windowMinutes: 60,
|
|
14497
14584
|
minSendsBeforeEval: 100
|
|
14498
14585
|
};
|
|
14586
|
+
var BROADCAST_STOP_RULE_DEFAULTS = {
|
|
14587
|
+
enabled: true,
|
|
14588
|
+
hardBounceRatePct: 2,
|
|
14589
|
+
complaintRatePct: 0.1,
|
|
14590
|
+
unsubscribeRatePct: 1,
|
|
14591
|
+
minSample: 100
|
|
14592
|
+
};
|
|
14499
14593
|
function resolveConfig(c) {
|
|
14500
14594
|
return {
|
|
14501
14595
|
...c,
|
|
@@ -14523,7 +14617,8 @@ function resolveConfig(c) {
|
|
|
14523
14617
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
14524
14618
|
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
14525
14619
|
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
14526
|
-
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
14620
|
+
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} },
|
|
14621
|
+
broadcastStopRules: { ...BROADCAST_STOP_RULE_DEFAULTS, ...c.broadcastStopRules ?? {} }
|
|
14527
14622
|
};
|
|
14528
14623
|
}
|
|
14529
14624
|
|
|
@@ -14741,11 +14836,13 @@ function resolveProvider(providers, name) {
|
|
|
14741
14836
|
function registeredProviderNames(providers) {
|
|
14742
14837
|
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
14743
14838
|
}
|
|
14839
|
+
var SEND_ID_RE = /^[a-f0-9]{24}$/i;
|
|
14744
14840
|
function signUnsubscribeToken(payload, secret) {
|
|
14745
14841
|
const body = JSON.stringify({
|
|
14746
14842
|
e: payload.email.toLowerCase(),
|
|
14747
14843
|
s: payload.scope,
|
|
14748
|
-
x: payload.expiresAt.getTime()
|
|
14844
|
+
x: payload.expiresAt.getTime(),
|
|
14845
|
+
...payload.sendId && SEND_ID_RE.test(payload.sendId) ? { i: payload.sendId } : {}
|
|
14749
14846
|
});
|
|
14750
14847
|
const bodyB64 = b64url(Buffer.from(body, "utf8"));
|
|
14751
14848
|
const hmac = crypto2.createHmac("sha256", secret).update(bodyB64).digest();
|
|
@@ -15304,11 +15401,11 @@ var ZERO_COUNTERS = {
|
|
|
15304
15401
|
complained: 0,
|
|
15305
15402
|
failedToSend: 0
|
|
15306
15403
|
};
|
|
15307
|
-
var
|
|
15308
|
-
bounceRate:
|
|
15309
|
-
hardBounceRate:
|
|
15310
|
-
complaintRate:
|
|
15311
|
-
failureRate:
|
|
15404
|
+
var EMPTY_RATES = {
|
|
15405
|
+
bounceRate: null,
|
|
15406
|
+
hardBounceRate: null,
|
|
15407
|
+
complaintRate: null,
|
|
15408
|
+
failureRate: null
|
|
15312
15409
|
};
|
|
15313
15410
|
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15314
15411
|
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
@@ -15338,7 +15435,7 @@ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowM
|
|
|
15338
15435
|
trippedAt: null,
|
|
15339
15436
|
trippedReason: null,
|
|
15340
15437
|
manuallyResumedAt: null,
|
|
15341
|
-
rates: { ...
|
|
15438
|
+
rates: { ...EMPTY_RATES }
|
|
15342
15439
|
},
|
|
15343
15440
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15344
15441
|
},
|
|
@@ -15365,7 +15462,7 @@ async function evaluateHealth(ctx) {
|
|
|
15365
15462
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15366
15463
|
windowDurationMs: windowMs,
|
|
15367
15464
|
counters: { ...ZERO_COUNTERS },
|
|
15368
|
-
rates: { ...
|
|
15465
|
+
rates: { ...EMPTY_RATES },
|
|
15369
15466
|
status: "healthy",
|
|
15370
15467
|
updatedAt: /* @__PURE__ */ new Date()
|
|
15371
15468
|
}
|
|
@@ -15373,13 +15470,21 @@ async function evaluateHealth(ctx) {
|
|
|
15373
15470
|
);
|
|
15374
15471
|
continue;
|
|
15375
15472
|
}
|
|
15376
|
-
const c = doc.counters;
|
|
15377
|
-
const
|
|
15473
|
+
const c = { ...ZERO_COUNTERS, ...doc.counters };
|
|
15474
|
+
const perSent = (n) => c.sent > 0 ? n / c.sent : null;
|
|
15475
|
+
const attempts = c.sent + c.failedToSend;
|
|
15378
15476
|
const rates = {
|
|
15379
|
-
bounceRate: c.bounced
|
|
15380
|
-
hardBounceRate: c.hardBounced
|
|
15381
|
-
complaintRate: c.complained
|
|
15382
|
-
|
|
15477
|
+
bounceRate: perSent(c.bounced),
|
|
15478
|
+
hardBounceRate: perSent(c.hardBounced),
|
|
15479
|
+
complaintRate: perSent(c.complained),
|
|
15480
|
+
// Failed attempts over all attempts, so it stays within 0–100%.
|
|
15481
|
+
failureRate: attempts > 0 ? c.failedToSend / attempts : null
|
|
15482
|
+
};
|
|
15483
|
+
const pct2 = {
|
|
15484
|
+
bounce: (rates.bounceRate ?? 0) * 100,
|
|
15485
|
+
hardBounce: (rates.hardBounceRate ?? 0) * 100,
|
|
15486
|
+
complaint: (rates.complaintRate ?? 0) * 100,
|
|
15487
|
+
failure: (rates.failureRate ?? 0) * 100
|
|
15383
15488
|
};
|
|
15384
15489
|
await ctx.collections.health.updateOne(
|
|
15385
15490
|
{ _id: doc._id },
|
|
@@ -15389,12 +15494,12 @@ async function evaluateHealth(ctx) {
|
|
|
15389
15494
|
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15390
15495
|
if (doc.status === "tripped") continue;
|
|
15391
15496
|
let trippedReason = null;
|
|
15392
|
-
if (
|
|
15393
|
-
trippedReason = `hard bounce rate ${
|
|
15394
|
-
} else if (
|
|
15395
|
-
trippedReason = `complaint rate ${
|
|
15396
|
-
} else if (
|
|
15397
|
-
trippedReason = `combined bounce rate ${
|
|
15497
|
+
if (pct2.hardBounce >= cb.hardBounceRatePctTrip) {
|
|
15498
|
+
trippedReason = `hard bounce rate ${pct2.hardBounce.toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15499
|
+
} else if (pct2.complaint >= cb.complaintRatePctTrip) {
|
|
15500
|
+
trippedReason = `complaint rate ${pct2.complaint.toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15501
|
+
} else if (pct2.bounce >= cb.combinedBounceRatePctTrip) {
|
|
15502
|
+
trippedReason = `combined bounce rate ${pct2.bounce.toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15398
15503
|
}
|
|
15399
15504
|
if (trippedReason) {
|
|
15400
15505
|
const result = await ctx.collections.health.updateOne(
|
|
@@ -15429,7 +15534,7 @@ async function evaluateHealth(ctx) {
|
|
|
15429
15534
|
}
|
|
15430
15535
|
continue;
|
|
15431
15536
|
}
|
|
15432
|
-
if (
|
|
15537
|
+
if (pct2.failure >= cb.failedToSendRatePctDegrade) {
|
|
15433
15538
|
if (doc.status !== "degraded") {
|
|
15434
15539
|
await ctx.collections.health.updateOne(
|
|
15435
15540
|
{ _id: doc._id },
|
|
@@ -15449,6 +15554,179 @@ async function getBucketStatus(ctx, fromEmail, kind) {
|
|
|
15449
15554
|
const id = healthBucketId(domain, kind);
|
|
15450
15555
|
return ctx.collections.health.findOne({ _id: id });
|
|
15451
15556
|
}
|
|
15557
|
+
function emptyBroadcastStats() {
|
|
15558
|
+
return withRates({
|
|
15559
|
+
total: 0,
|
|
15560
|
+
accepted: 0,
|
|
15561
|
+
delivered: 0,
|
|
15562
|
+
bounced: 0,
|
|
15563
|
+
hardBounced: 0,
|
|
15564
|
+
softBounced: 0,
|
|
15565
|
+
complained: 0,
|
|
15566
|
+
unsubscribed: 0,
|
|
15567
|
+
opened: 0,
|
|
15568
|
+
clicked: 0,
|
|
15569
|
+
outcomes: 0
|
|
15570
|
+
});
|
|
15571
|
+
}
|
|
15572
|
+
var pct = (n, d) => d > 0 ? Math.round(n / d * 1e4) / 100 : 0;
|
|
15573
|
+
function withRates(c) {
|
|
15574
|
+
return {
|
|
15575
|
+
...c,
|
|
15576
|
+
rates: {
|
|
15577
|
+
deliveryRatePct: pct(c.delivered, c.accepted),
|
|
15578
|
+
bounceRatePct: pct(c.bounced, c.outcomes),
|
|
15579
|
+
hardBounceRatePct: pct(c.hardBounced, c.outcomes),
|
|
15580
|
+
complaintRatePct: pct(c.complained, c.outcomes),
|
|
15581
|
+
unsubscribeRatePct: pct(c.unsubscribed, c.outcomes),
|
|
15582
|
+
openRatePct: pct(c.opened, c.delivered),
|
|
15583
|
+
clickRatePct: pct(c.clicked, c.delivered)
|
|
15584
|
+
}
|
|
15585
|
+
};
|
|
15586
|
+
}
|
|
15587
|
+
var set = (field) => ({ $ifNull: [field, false] });
|
|
15588
|
+
var DELIVERED = {
|
|
15589
|
+
$or: [set("$deliveredAt"), { $eq: ["$status", "delivered"] }, set("$complainedAt"), { $eq: ["$status", "complained"] }]
|
|
15590
|
+
};
|
|
15591
|
+
var BOUNCED = { $or: [{ $eq: ["$status", "bounced"] }, { $in: [{ $ifNull: ["$bounceType", null] }, ["hard", "soft"]] }] };
|
|
15592
|
+
var count = (expr) => ({ $sum: { $cond: [expr, 1, 0] } });
|
|
15593
|
+
async function aggregateBroadcastStats(collections, broadcastId) {
|
|
15594
|
+
const rows = await collections.sends.aggregate([
|
|
15595
|
+
{ $match: broadcastId ? { broadcastId } : { broadcastId: { $ne: null } } },
|
|
15596
|
+
{
|
|
15597
|
+
$group: {
|
|
15598
|
+
_id: "$broadcastId",
|
|
15599
|
+
total: { $sum: 1 },
|
|
15600
|
+
accepted: count(set("$sentAt")),
|
|
15601
|
+
delivered: count(DELIVERED),
|
|
15602
|
+
bounced: count(BOUNCED),
|
|
15603
|
+
hardBounced: count({ $eq: ["$bounceType", "hard"] }),
|
|
15604
|
+
softBounced: count({ $eq: ["$bounceType", "soft"] }),
|
|
15605
|
+
complained: count({ $or: [set("$complainedAt"), { $eq: ["$status", "complained"] }] }),
|
|
15606
|
+
unsubscribed: count(set("$unsubscribedAt")),
|
|
15607
|
+
opened: count(set("$openedAt")),
|
|
15608
|
+
clicked: count(set("$firstClickAt")),
|
|
15609
|
+
outcomes: count({ $or: [DELIVERED, BOUNCED] })
|
|
15610
|
+
}
|
|
15611
|
+
}
|
|
15612
|
+
]).toArray();
|
|
15613
|
+
const out = /* @__PURE__ */ new Map();
|
|
15614
|
+
for (const { _id, ...c } of rows) {
|
|
15615
|
+
if (_id) out.set(String(_id), withRates(c));
|
|
15616
|
+
}
|
|
15617
|
+
return out;
|
|
15618
|
+
}
|
|
15619
|
+
async function broadcastStatsFor(collections, broadcastId) {
|
|
15620
|
+
return (await aggregateBroadcastStats(collections, broadcastId)).get(String(broadcastId)) ?? emptyBroadcastStats();
|
|
15621
|
+
}
|
|
15622
|
+
function effectiveStopRules(config, b) {
|
|
15623
|
+
return { ...config.broadcastStopRules, ...b.stopRules ?? {} };
|
|
15624
|
+
}
|
|
15625
|
+
var RULE_COUNTS = [
|
|
15626
|
+
["complaintRatePct", "complained"],
|
|
15627
|
+
["hardBounceRatePct", "hardBounced"],
|
|
15628
|
+
["unsubscribeRatePct", "unsubscribed"]
|
|
15629
|
+
];
|
|
15630
|
+
function evaluateStopRules(stats, rules) {
|
|
15631
|
+
const sample = stats.outcomes;
|
|
15632
|
+
const evaluated = rules.enabled && sample > 0 && sample >= rules.minSample;
|
|
15633
|
+
const breaches = [];
|
|
15634
|
+
if (evaluated) {
|
|
15635
|
+
for (const [rule, key] of RULE_COUNTS) {
|
|
15636
|
+
const n = stats[key];
|
|
15637
|
+
const exactPct = n / sample * 100;
|
|
15638
|
+
if (exactPct > rules[rule]) {
|
|
15639
|
+
breaches.push({ rule, count: n, ratePct: Math.round(exactPct * 100) / 100, thresholdPct: rules[rule] });
|
|
15640
|
+
}
|
|
15641
|
+
}
|
|
15642
|
+
}
|
|
15643
|
+
return { rules, sample, evaluated, breaches };
|
|
15644
|
+
}
|
|
15645
|
+
var RULE_LABEL = {
|
|
15646
|
+
complaintRatePct: "complaint rate",
|
|
15647
|
+
hardBounceRatePct: "hard bounce rate",
|
|
15648
|
+
unsubscribeRatePct: "unsubscribe rate"
|
|
15649
|
+
};
|
|
15650
|
+
var PAUSE_PRECEDENCE = {
|
|
15651
|
+
manual: 1,
|
|
15652
|
+
cap_reached: 1,
|
|
15653
|
+
stop_rule: 2,
|
|
15654
|
+
circuit_breaker: 2
|
|
15655
|
+
};
|
|
15656
|
+
async function pauseBroadcast(ctx, broadcastId, reason, actor = "system:broadcast") {
|
|
15657
|
+
const weaker = Object.keys(PAUSE_PRECEDENCE).filter(
|
|
15658
|
+
(k) => PAUSE_PRECEDENCE[k] < PAUSE_PRECEDENCE[reason.code]
|
|
15659
|
+
);
|
|
15660
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
15661
|
+
{
|
|
15662
|
+
_id: broadcastId,
|
|
15663
|
+
$or: [{ status: { $in: ["sending", "sent"] } }, { status: "paused", "pauseReason.code": { $in: weaker } }]
|
|
15664
|
+
},
|
|
15665
|
+
{ $set: { status: "paused", pausedAt: reason.at, pauseReason: reason, updatedAt: /* @__PURE__ */ new Date() } },
|
|
15666
|
+
{ returnDocument: "after" }
|
|
15667
|
+
);
|
|
15668
|
+
if (!b) return { paused: false, heldSends: 0 };
|
|
15669
|
+
const held = await holdQueuedSends(ctx, broadcastId, reason.code);
|
|
15670
|
+
if (ctx.audit) {
|
|
15671
|
+
await ctx.audit({
|
|
15672
|
+
actor,
|
|
15673
|
+
action: "broadcast.pause",
|
|
15674
|
+
resource: { collection: "mailer_broadcasts", id: String(broadcastId), slug: b.slug },
|
|
15675
|
+
diffSummary: `${reason.code}: ${reason.message} \xB7 held ${held} queued send(s)`
|
|
15676
|
+
}).catch(() => {
|
|
15677
|
+
});
|
|
15678
|
+
}
|
|
15679
|
+
if (ctx.config.onBroadcastPaused) {
|
|
15680
|
+
try {
|
|
15681
|
+
await ctx.config.onBroadcastPaused({ broadcastId: String(broadcastId), slug: b.slug, reason, heldSends: held });
|
|
15682
|
+
} catch {
|
|
15683
|
+
}
|
|
15684
|
+
}
|
|
15685
|
+
return { paused: true, heldSends: held };
|
|
15686
|
+
}
|
|
15687
|
+
async function holdQueuedSends(ctx, broadcastId, code) {
|
|
15688
|
+
const res = await ctx.collections.sends.updateMany(
|
|
15689
|
+
{ broadcastId, status: "queued" },
|
|
15690
|
+
{ $set: { status: "held", errorMessage: `held: broadcast paused (${code})`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15691
|
+
);
|
|
15692
|
+
return res.modifiedCount;
|
|
15693
|
+
}
|
|
15694
|
+
async function evaluateBroadcastStopRules(ctx, broadcastId) {
|
|
15695
|
+
const b = await ctx.collections.broadcasts.findOne({ _id: broadcastId });
|
|
15696
|
+
if (!b || !["sending", "sent", "paused"].includes(b.status)) return null;
|
|
15697
|
+
if (b.status === "paused" && b.pauseReason && PAUSE_PRECEDENCE[b.pauseReason.code] >= 2) return null;
|
|
15698
|
+
const stats = await broadcastStatsFor(ctx.collections, broadcastId);
|
|
15699
|
+
const evaluation = evaluateStopRules(stats, effectiveStopRules(ctx.config, b));
|
|
15700
|
+
if (evaluation.breaches.length === 0) return evaluation;
|
|
15701
|
+
const now = /* @__PURE__ */ new Date();
|
|
15702
|
+
if (b.status === "sent" && await ctx.collections.sends.countDocuments({ broadcastId, status: "queued" }) === 0) {
|
|
15703
|
+
await ctx.collections.broadcasts.updateOne(
|
|
15704
|
+
{ _id: broadcastId, stopRuleBreach: { $in: [null] } },
|
|
15705
|
+
{ $set: { stopRuleBreach: { at: now, sample: evaluation.sample, breaches: evaluation.breaches } } }
|
|
15706
|
+
);
|
|
15707
|
+
return evaluation;
|
|
15708
|
+
}
|
|
15709
|
+
const first = evaluation.breaches[0];
|
|
15710
|
+
await pauseBroadcast(ctx, broadcastId, {
|
|
15711
|
+
code: "stop_rule",
|
|
15712
|
+
message: `${RULE_LABEL[first.rule]} ${first.ratePct}% is over ${first.thresholdPct}% (${first.count} of ${evaluation.sample} sends with an outcome)`,
|
|
15713
|
+
at: now,
|
|
15714
|
+
details: { breaches: evaluation.breaches, sample: evaluation.sample, rules: evaluation.rules }
|
|
15715
|
+
});
|
|
15716
|
+
return evaluation;
|
|
15717
|
+
}
|
|
15718
|
+
var STOP_RULE_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
15719
|
+
async function evaluateActiveBroadcastStopRules(ctx) {
|
|
15720
|
+
const live = await ctx.collections.broadcasts.find(
|
|
15721
|
+
{ status: { $in: ["sending", "sent", "paused"] }, startedAt: { $gte: new Date(Date.now() - STOP_RULE_WINDOW_MS) } },
|
|
15722
|
+
{ projection: { _id: 1 } }
|
|
15723
|
+
).limit(200).toArray();
|
|
15724
|
+
for (const { _id } of live) {
|
|
15725
|
+
await evaluateBroadcastStopRules(ctx, _id).catch((err) => {
|
|
15726
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(_id), err });
|
|
15727
|
+
});
|
|
15728
|
+
}
|
|
15729
|
+
}
|
|
15452
15730
|
|
|
15453
15731
|
// src/server/runner/webhook.ts
|
|
15454
15732
|
function dimsFromSend(send) {
|
|
@@ -15599,6 +15877,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
15599
15877
|
);
|
|
15600
15878
|
break;
|
|
15601
15879
|
}
|
|
15880
|
+
if (send?.broadcastId && (event.type === "bounce" || event.type === "complaint" || event.type === "spam_report" || event.type === "unsubscribe")) {
|
|
15881
|
+
await evaluateBroadcastStopRules(ctx, send.broadcastId).catch((err) => {
|
|
15882
|
+
console.error("mailery: broadcast stop-rule evaluation failed", { id: String(send.broadcastId), err });
|
|
15883
|
+
});
|
|
15884
|
+
}
|
|
15602
15885
|
}
|
|
15603
15886
|
async function suppressOnce(ctx, email, reason, scope) {
|
|
15604
15887
|
const normalized = email.toLowerCase();
|
|
@@ -15996,11 +16279,30 @@ async function isSuppressed(collections, email, kind) {
|
|
|
15996
16279
|
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
15997
16280
|
const hashed = await collections.suppressions.findOne({
|
|
15998
16281
|
emailHash: sha256Hex(normalized),
|
|
15999
|
-
scope: { $in: allowed }
|
|
16282
|
+
scope: { $in: allowed },
|
|
16283
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
16000
16284
|
});
|
|
16001
16285
|
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
16002
16286
|
return { suppressed: false };
|
|
16003
16287
|
}
|
|
16288
|
+
async function suppressedEmails(collections, emails, kind) {
|
|
16289
|
+
const normalized = [...new Set(emails.map((e) => e.toLowerCase()))];
|
|
16290
|
+
if (normalized.length === 0) return /* @__PURE__ */ new Set();
|
|
16291
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
16292
|
+
const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }] };
|
|
16293
|
+
const byHash = new Map(normalized.map((e) => [sha256Hex(e), e]));
|
|
16294
|
+
const [plain, hashed] = await Promise.all([
|
|
16295
|
+
collections.suppressions.distinct("email", { email: { $in: normalized }, scope: { $in: allowed }, ...live }),
|
|
16296
|
+
collections.suppressions.distinct("emailHash", { emailHash: { $in: [...byHash.keys()] }, scope: { $in: allowed }, ...live })
|
|
16297
|
+
]);
|
|
16298
|
+
const out = /* @__PURE__ */ new Set();
|
|
16299
|
+
for (const e of plain) if (typeof e === "string") out.add(e);
|
|
16300
|
+
for (const h of hashed) {
|
|
16301
|
+
const e = byHash.get(String(h));
|
|
16302
|
+
if (e) out.add(e);
|
|
16303
|
+
}
|
|
16304
|
+
return out;
|
|
16305
|
+
}
|
|
16004
16306
|
|
|
16005
16307
|
// src/server/runner/send.ts
|
|
16006
16308
|
async function handleSend(run, step2, contact, flow, ctx) {
|
|
@@ -16077,6 +16379,27 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16077
16379
|
await markFailed(send._id, "template_missing", ctx);
|
|
16078
16380
|
return;
|
|
16079
16381
|
}
|
|
16382
|
+
if (send.broadcastId) {
|
|
16383
|
+
const broadcast = await ctx.collections.broadcasts.findOne(
|
|
16384
|
+
{ _id: send.broadcastId },
|
|
16385
|
+
{ projection: { status: 1, pauseReason: 1 } }
|
|
16386
|
+
);
|
|
16387
|
+
const holding = broadcast?.status === "paused" && broadcast.pauseReason?.code !== "cap_reached";
|
|
16388
|
+
if (holding || broadcast?.status === "cancelled") {
|
|
16389
|
+
const held = holding;
|
|
16390
|
+
await ctx.collections.sends.updateOne(
|
|
16391
|
+
{ _id: send._id },
|
|
16392
|
+
{
|
|
16393
|
+
$set: {
|
|
16394
|
+
status: held ? "held" : "cancelled",
|
|
16395
|
+
errorMessage: held ? `held: broadcast paused (${broadcast.pauseReason?.code ?? "unknown"})` : "cancelled: broadcast cancelled",
|
|
16396
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
16397
|
+
}
|
|
16398
|
+
}
|
|
16399
|
+
);
|
|
16400
|
+
return;
|
|
16401
|
+
}
|
|
16402
|
+
}
|
|
16080
16403
|
const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
|
|
16081
16404
|
if (supp.suppressed) {
|
|
16082
16405
|
await ctx.collections.sends.updateOne(
|
|
@@ -16087,6 +16410,19 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16087
16410
|
}
|
|
16088
16411
|
if (send.kind === "marketing") {
|
|
16089
16412
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
16413
|
+
if (bucket?.status === "tripped" && send.broadcastId) {
|
|
16414
|
+
await ctx.collections.sends.updateOne(
|
|
16415
|
+
{ _id: send._id },
|
|
16416
|
+
{ $set: { status: "held", errorMessage: "held: circuit breaker tripped", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16417
|
+
);
|
|
16418
|
+
await pauseBroadcast(ctx, send.broadcastId, {
|
|
16419
|
+
code: "circuit_breaker",
|
|
16420
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${send.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
16421
|
+
at: /* @__PURE__ */ new Date(),
|
|
16422
|
+
details: { bucket: bucket._id }
|
|
16423
|
+
});
|
|
16424
|
+
return;
|
|
16425
|
+
}
|
|
16090
16426
|
if (bucket?.status === "tripped") {
|
|
16091
16427
|
await ctx.collections.sends.updateOne(
|
|
16092
16428
|
{ _id: send._id },
|
|
@@ -16101,6 +16437,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16101
16437
|
await markFailed(send._id, "contact_missing", ctx);
|
|
16102
16438
|
return;
|
|
16103
16439
|
}
|
|
16440
|
+
if (!send.emailAtSend?.trim() || !contact.email?.trim()) {
|
|
16441
|
+
await markFailed(send._id, "no_recipient: the contact has no email address", ctx);
|
|
16442
|
+
return;
|
|
16443
|
+
}
|
|
16104
16444
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
16105
16445
|
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
16106
16446
|
await ctx.collections.sends.updateOne(
|
|
@@ -16119,7 +16459,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
16119
16459
|
eventName: run?.triggerEvent?.name,
|
|
16120
16460
|
eventProperties: run?.triggerEvent?.properties
|
|
16121
16461
|
});
|
|
16122
|
-
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
16462
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved, String(send._id));
|
|
16123
16463
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
16124
16464
|
} catch (err) {
|
|
16125
16465
|
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
@@ -16210,11 +16550,11 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
16210
16550
|
}
|
|
16211
16551
|
return ctx.config.defaultProvider;
|
|
16212
16552
|
}
|
|
16213
|
-
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
16553
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}, sendId) {
|
|
16214
16554
|
const scope = "marketing";
|
|
16215
16555
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
16216
16556
|
const token = signUnsubscribeToken(
|
|
16217
|
-
{ email: contact.email, scope, expiresAt },
|
|
16557
|
+
{ email: contact.email, scope, expiresAt, ...sendId ? { sendId } : {} },
|
|
16218
16558
|
ctx.config.unsubscribeSecret
|
|
16219
16559
|
);
|
|
16220
16560
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
@@ -16297,6 +16637,10 @@ async function processOneRunStep(runId, ctx) {
|
|
|
16297
16637
|
await exitFlowRun(run, "contact_missing", ctx);
|
|
16298
16638
|
return;
|
|
16299
16639
|
}
|
|
16640
|
+
if (!contact.email?.trim()) {
|
|
16641
|
+
await exitFlowRun(run, "contact_no_email", ctx);
|
|
16642
|
+
return;
|
|
16643
|
+
}
|
|
16300
16644
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
|
|
16301
16645
|
if (sub && sub.status !== "subscribed") {
|
|
16302
16646
|
await exitFlowRun(run, sub.status, ctx);
|
|
@@ -16590,6 +16934,184 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16590
16934
|
}
|
|
16591
16935
|
}
|
|
16592
16936
|
}
|
|
16937
|
+
|
|
16938
|
+
// src/server/runner/segment.ts
|
|
16939
|
+
function planSegment(seg) {
|
|
16940
|
+
const hostFilter = {};
|
|
16941
|
+
const postFilters = [];
|
|
16942
|
+
const hostFields = /* @__PURE__ */ new Set();
|
|
16943
|
+
const takeField = (field) => {
|
|
16944
|
+
if (hostFields.has(field)) return false;
|
|
16945
|
+
hostFields.add(field);
|
|
16946
|
+
return true;
|
|
16947
|
+
};
|
|
16948
|
+
for (const f of seg.filters ?? []) {
|
|
16949
|
+
switch (f.kind) {
|
|
16950
|
+
case "hasTag":
|
|
16951
|
+
if (hostFilter.hasTag === void 0) {
|
|
16952
|
+
hostFilter.hasTag = f.tag;
|
|
16953
|
+
continue;
|
|
16954
|
+
}
|
|
16955
|
+
break;
|
|
16956
|
+
case "fieldEquals":
|
|
16957
|
+
if (!hostFilter.fieldEquals && takeField(f.field)) {
|
|
16958
|
+
hostFilter.fieldEquals = { field: f.field, value: f.value };
|
|
16959
|
+
continue;
|
|
16960
|
+
}
|
|
16961
|
+
break;
|
|
16962
|
+
case "fieldIn":
|
|
16963
|
+
if (!hostFilter.fieldIn && takeField(f.field)) {
|
|
16964
|
+
hostFilter.fieldIn = { field: f.field, values: f.values };
|
|
16965
|
+
continue;
|
|
16966
|
+
}
|
|
16967
|
+
break;
|
|
16968
|
+
case "fieldExists":
|
|
16969
|
+
if (!hostFilter.fieldExists && takeField(f.field)) {
|
|
16970
|
+
hostFilter.fieldExists = f.field;
|
|
16971
|
+
continue;
|
|
16972
|
+
}
|
|
16973
|
+
break;
|
|
16974
|
+
}
|
|
16975
|
+
postFilters.push(f);
|
|
16976
|
+
}
|
|
16977
|
+
return { hostFilter, postFilters };
|
|
16978
|
+
}
|
|
16979
|
+
async function applyPostFilters(contacts, filters, ctx, now = /* @__PURE__ */ new Date()) {
|
|
16980
|
+
if (filters.length === 0 || contacts.length === 0) return contacts;
|
|
16981
|
+
const externalIds = contacts.map((c) => c.externalId);
|
|
16982
|
+
const leaves = /* @__PURE__ */ new Map();
|
|
16983
|
+
collectLookups(filters, leaves);
|
|
16984
|
+
const cache = /* @__PURE__ */ new Map();
|
|
16985
|
+
await Promise.all(
|
|
16986
|
+
[...leaves].map(async ([key, f]) => {
|
|
16987
|
+
cache.set(key, await lookup(f, externalIds, ctx, now));
|
|
16988
|
+
})
|
|
16989
|
+
);
|
|
16990
|
+
return contacts.filter((c) => filters.every((f) => matches(c, f, cache)));
|
|
16991
|
+
}
|
|
16992
|
+
function lookupKey(f) {
|
|
16993
|
+
switch (f.kind) {
|
|
16994
|
+
case "subscriptionStatus":
|
|
16995
|
+
return `sub:${f.equals}`;
|
|
16996
|
+
case "firedEvent":
|
|
16997
|
+
case "notFiredEvent":
|
|
16998
|
+
return `evt:${f.withinDays ?? ""}:${f.eventName}`;
|
|
16999
|
+
case "opened":
|
|
17000
|
+
case "notOpened":
|
|
17001
|
+
return `open:${f.withinDays ?? ""}:${f.templateSlug ?? ""}`;
|
|
17002
|
+
case "subscribedAfter":
|
|
17003
|
+
return `subAfter:${toDate(f.date).getTime()}`;
|
|
17004
|
+
case "subscribedBefore":
|
|
17005
|
+
return `subBefore:${toDate(f.date).getTime()}`;
|
|
17006
|
+
default:
|
|
17007
|
+
return null;
|
|
17008
|
+
}
|
|
17009
|
+
}
|
|
17010
|
+
function collectLookups(filters, out) {
|
|
17011
|
+
for (const f of filters) {
|
|
17012
|
+
if (f.kind === "any") collectLookups(f.filters, out);
|
|
17013
|
+
else if (f.kind === "not") collectLookups([f.filter], out);
|
|
17014
|
+
else {
|
|
17015
|
+
const key = lookupKey(f);
|
|
17016
|
+
if (key && !out.has(key)) out.set(key, f);
|
|
17017
|
+
}
|
|
17018
|
+
}
|
|
17019
|
+
}
|
|
17020
|
+
async function lookup(f, externalIds, ctx, now) {
|
|
17021
|
+
const c = ctx.collections;
|
|
17022
|
+
const inPage = { $in: externalIds };
|
|
17023
|
+
const since = (days) => days ? new Date(now.getTime() - days * 864e5) : null;
|
|
17024
|
+
let ids;
|
|
17025
|
+
switch (f.kind) {
|
|
17026
|
+
case "subscriptionStatus":
|
|
17027
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, status: f.equals });
|
|
17028
|
+
break;
|
|
17029
|
+
case "firedEvent":
|
|
17030
|
+
case "notFiredEvent": {
|
|
17031
|
+
const cutoff = since(f.withinDays);
|
|
17032
|
+
ids = await c.events.distinct("externalId", {
|
|
17033
|
+
externalId: inPage,
|
|
17034
|
+
name: f.eventName,
|
|
17035
|
+
...cutoff ? { occurredAt: { $gt: cutoff } } : {}
|
|
17036
|
+
});
|
|
17037
|
+
break;
|
|
17038
|
+
}
|
|
17039
|
+
case "opened":
|
|
17040
|
+
case "notOpened": {
|
|
17041
|
+
const cutoff = since(f.withinDays);
|
|
17042
|
+
ids = await c.sends.distinct("externalId", {
|
|
17043
|
+
externalId: inPage,
|
|
17044
|
+
openedAt: cutoff ? { $gt: cutoff } : { $ne: null },
|
|
17045
|
+
...f.templateSlug ? { templateSlug: f.templateSlug } : {}
|
|
17046
|
+
});
|
|
17047
|
+
break;
|
|
17048
|
+
}
|
|
17049
|
+
case "subscribedAfter":
|
|
17050
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $gt: toDate(f.date) } });
|
|
17051
|
+
break;
|
|
17052
|
+
case "subscribedBefore":
|
|
17053
|
+
ids = await c.subscriptions.distinct("externalId", { externalId: inPage, subscribedAt: { $lt: toDate(f.date) } });
|
|
17054
|
+
break;
|
|
17055
|
+
default:
|
|
17056
|
+
ids = [];
|
|
17057
|
+
}
|
|
17058
|
+
return new Set(ids.map(String));
|
|
17059
|
+
}
|
|
17060
|
+
function matches(c, f, cache) {
|
|
17061
|
+
switch (f.kind) {
|
|
17062
|
+
case "subscriptionStatus":
|
|
17063
|
+
case "firedEvent":
|
|
17064
|
+
case "opened":
|
|
17065
|
+
case "subscribedAfter":
|
|
17066
|
+
case "subscribedBefore":
|
|
17067
|
+
return cache.get(lookupKey(f))?.has(c.externalId) ?? false;
|
|
17068
|
+
case "notFiredEvent":
|
|
17069
|
+
case "notOpened":
|
|
17070
|
+
return !(cache.get(lookupKey(f))?.has(c.externalId) ?? false);
|
|
17071
|
+
case "hasTag":
|
|
17072
|
+
return c.tags.includes(f.tag);
|
|
17073
|
+
case "notHasTag":
|
|
17074
|
+
return !c.tags.includes(f.tag);
|
|
17075
|
+
case "fieldEquals":
|
|
17076
|
+
return valuesEqual(fieldValue(c, f.field), f.value);
|
|
17077
|
+
case "fieldIn": {
|
|
17078
|
+
const v = fieldValue(c, f.field);
|
|
17079
|
+
return f.values.some((x) => valuesEqual(v, x));
|
|
17080
|
+
}
|
|
17081
|
+
case "fieldExists":
|
|
17082
|
+
return fieldValue(c, f.field) !== void 0;
|
|
17083
|
+
case "any":
|
|
17084
|
+
return f.filters.some((sub) => matches(c, sub, cache));
|
|
17085
|
+
case "not":
|
|
17086
|
+
return !matches(c, f.filter, cache);
|
|
17087
|
+
default:
|
|
17088
|
+
throw new Error(`mailery: unknown segment filter kind "${f.kind}"`);
|
|
17089
|
+
}
|
|
17090
|
+
}
|
|
17091
|
+
function fieldValue(c, path2) {
|
|
17092
|
+
let cur = c.fields;
|
|
17093
|
+
for (const part of path2.split(".")) {
|
|
17094
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
17095
|
+
cur = cur[part];
|
|
17096
|
+
}
|
|
17097
|
+
return cur;
|
|
17098
|
+
}
|
|
17099
|
+
function valuesEqual(a, b) {
|
|
17100
|
+
if (a instanceof Date || b instanceof Date) {
|
|
17101
|
+
const ta = a instanceof Date ? a.getTime() : new Date(a).getTime();
|
|
17102
|
+
const tb = b instanceof Date ? b.getTime() : new Date(b).getTime();
|
|
17103
|
+
return !Number.isNaN(ta) && ta === tb;
|
|
17104
|
+
}
|
|
17105
|
+
if (a && typeof a === "object" && typeof a.toHexString === "function") {
|
|
17106
|
+
return String(a) === String(b);
|
|
17107
|
+
}
|
|
17108
|
+
return a === b;
|
|
17109
|
+
}
|
|
17110
|
+
function toDate(d) {
|
|
17111
|
+
return d instanceof Date ? d : new Date(d);
|
|
17112
|
+
}
|
|
17113
|
+
|
|
17114
|
+
// src/server/runner/broadcasts.ts
|
|
16593
17115
|
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16594
17116
|
async function processScheduledBroadcasts(ctx) {
|
|
16595
17117
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -16597,7 +17119,7 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16597
17119
|
for (const b of due) {
|
|
16598
17120
|
const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
16599
17121
|
{ _id: b._id, status: "scheduled" },
|
|
16600
|
-
{ $set: { status: "sending", startedAt: now, updatedAt: now } },
|
|
17122
|
+
{ $set: { status: "sending", startedAt: now, updatedAt: now, dispatchLeaseId: null } },
|
|
16601
17123
|
{ returnDocument: "after" }
|
|
16602
17124
|
);
|
|
16603
17125
|
if (!claimed) continue;
|
|
@@ -16605,8 +17127,14 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16605
17127
|
}
|
|
16606
17128
|
}
|
|
16607
17129
|
async function startBroadcastDispatch(broadcast, ctx) {
|
|
17130
|
+
const bumped = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
17131
|
+
{ _id: broadcast._id },
|
|
17132
|
+
{ $inc: { dispatchGeneration: 1 } },
|
|
17133
|
+
{ returnDocument: "after" }
|
|
17134
|
+
);
|
|
17135
|
+
const current = bumped ?? broadcast;
|
|
16608
17136
|
if (ctx.config.queue.driver === "noop") {
|
|
16609
|
-
await runBroadcastDispatch(
|
|
17137
|
+
await runBroadcastDispatch(current, ctx);
|
|
16610
17138
|
return;
|
|
16611
17139
|
}
|
|
16612
17140
|
await ctx.queues.advance.add(
|
|
@@ -16615,7 +17143,7 @@ async function startBroadcastDispatch(broadcast, ctx) {
|
|
|
16615
17143
|
{
|
|
16616
17144
|
attempts: 3,
|
|
16617
17145
|
backoff: { type: "exponential", delay: 6e4 },
|
|
16618
|
-
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
17146
|
+
jobId: `broadcast-dispatch:${broadcast._id}:${current.dispatchGeneration ?? 0}`
|
|
16619
17147
|
}
|
|
16620
17148
|
);
|
|
16621
17149
|
}
|
|
@@ -16628,10 +17156,11 @@ async function resumeStalledBroadcasts(ctx) {
|
|
|
16628
17156
|
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16629
17157
|
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16630
17158
|
for (const b of stalled) {
|
|
16631
|
-
await ctx.collections.broadcasts.updateOne(
|
|
16632
|
-
{ _id: b._id },
|
|
16633
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
17159
|
+
const reset = await ctx.collections.broadcasts.updateOne(
|
|
17160
|
+
{ _id: b._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
17161
|
+
{ $set: { dispatchLeaseId: null, updatedAt: /* @__PURE__ */ new Date() } }
|
|
16634
17162
|
);
|
|
17163
|
+
if (reset.modifiedCount === 0) continue;
|
|
16635
17164
|
await startBroadcastDispatch(b, ctx);
|
|
16636
17165
|
}
|
|
16637
17166
|
}
|
|
@@ -16641,50 +17170,117 @@ async function runBroadcastDispatch(broadcast, ctx) {
|
|
|
16641
17170
|
} catch (err) {
|
|
16642
17171
|
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16643
17172
|
await ctx.collections.broadcasts.updateOne(
|
|
16644
|
-
{ _id: broadcast._id },
|
|
16645
|
-
{
|
|
17173
|
+
{ _id: broadcast._id, status: "sending" },
|
|
17174
|
+
{
|
|
17175
|
+
$set: {
|
|
17176
|
+
status: "failed",
|
|
17177
|
+
failureReason: `dispatch error: ${String(err?.message ?? err)}`,
|
|
17178
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17179
|
+
dispatchLeaseId: null
|
|
17180
|
+
}
|
|
17181
|
+
}
|
|
16646
17182
|
);
|
|
16647
17183
|
}
|
|
16648
17184
|
}
|
|
17185
|
+
function broadcastDedupeKey(broadcastId, externalId) {
|
|
17186
|
+
return `broadcast:${broadcastId}:${externalId}`;
|
|
17187
|
+
}
|
|
17188
|
+
var SENDABLE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
17189
|
+
function isSendableEmail(email) {
|
|
17190
|
+
return typeof email === "string" && SENDABLE_EMAIL_RE.test(email);
|
|
17191
|
+
}
|
|
17192
|
+
async function* eligibleRecipientPages(broadcast, kind, ctx) {
|
|
17193
|
+
const { hostFilter, postFilters } = planSegment(broadcast.segmentDefinition);
|
|
17194
|
+
const sort = broadcast.order ?? void 0;
|
|
17195
|
+
if (sort && !ctx.adapter.supportsSort) {
|
|
17196
|
+
throw new Error("mailery: this broadcast has an order but the contact adapter does not support sorted queries");
|
|
17197
|
+
}
|
|
17198
|
+
let cursor;
|
|
17199
|
+
for (; ; ) {
|
|
17200
|
+
const page = await ctx.adapter.query(hostFilter, {
|
|
17201
|
+
limit: ctx.config.broadcastEnqueueBatchSize,
|
|
17202
|
+
cursor,
|
|
17203
|
+
...sort ? { sort: { field: sort.field, direction: sort.direction } } : {}
|
|
17204
|
+
});
|
|
17205
|
+
if (page.contacts.length === 0) break;
|
|
17206
|
+
const passed = (await applyPostFilters(page.contacts, postFilters, ctx)).filter((c) => isSendableEmail(c.email));
|
|
17207
|
+
const suppressed = await suppressedEmails(ctx.collections, passed.map((c) => c.email), kind);
|
|
17208
|
+
yield passed.filter((c) => !suppressed.has(c.email.toLowerCase()));
|
|
17209
|
+
if (!page.nextCursor) break;
|
|
17210
|
+
cursor = page.nextCursor;
|
|
17211
|
+
}
|
|
17212
|
+
}
|
|
17213
|
+
async function alreadyDispatched(ctx, broadcastId, contacts) {
|
|
17214
|
+
if (contacts.length === 0) return /* @__PURE__ */ new Set();
|
|
17215
|
+
const keys = contacts.map((c) => broadcastDedupeKey(broadcastId, c.externalId));
|
|
17216
|
+
const found = await ctx.collections.sends.distinct("dedupeKey", { dedupeKey: { $in: keys } });
|
|
17217
|
+
return new Set(found.map(String));
|
|
17218
|
+
}
|
|
16649
17219
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
16650
17220
|
const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
|
|
16651
|
-
|
|
17221
|
+
const failure = !template ? `template "${broadcast.templateSlug}" not found` : template.kind !== "marketing" ? `template "${broadcast.templateSlug}" is ${template.kind}, not marketing` : null;
|
|
17222
|
+
if (failure || !template) {
|
|
16652
17223
|
await ctx.collections.broadcasts.updateOne(
|
|
16653
|
-
{ _id: broadcast._id },
|
|
16654
|
-
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17224
|
+
{ _id: broadcast._id, status: "sending" },
|
|
17225
|
+
{ $set: { status: "failed", failureReason: failure, updatedAt: /* @__PURE__ */ new Date(), dispatchLeaseId: null } }
|
|
16655
17226
|
);
|
|
16656
17227
|
return;
|
|
16657
17228
|
}
|
|
16658
|
-
const
|
|
16659
|
-
const
|
|
16660
|
-
|
|
16661
|
-
|
|
16662
|
-
|
|
17229
|
+
const leaseId = new ObjectId().toHexString();
|
|
17230
|
+
const staleCutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
17231
|
+
const b = await ctx.collections.broadcasts.findOneAndUpdate(
|
|
17232
|
+
{
|
|
17233
|
+
_id: broadcast._id,
|
|
17234
|
+
status: "sending",
|
|
17235
|
+
$or: [{ dispatchLeaseId: null }, { dispatchLeaseId: { $exists: false } }, { updatedAt: { $lt: staleCutoff } }]
|
|
17236
|
+
},
|
|
17237
|
+
{ $set: { dispatchLeaseId: leaseId, updatedAt: /* @__PURE__ */ new Date() } },
|
|
17238
|
+
{ returnDocument: "after" }
|
|
17239
|
+
);
|
|
17240
|
+
if (!b) return;
|
|
17241
|
+
const holdsLease = async () => (await ctx.collections.broadcasts.updateOne(
|
|
17242
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
17243
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
17244
|
+
)).matchedCount === 1;
|
|
16663
17245
|
const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
|
|
16664
|
-
const respectTimezone =
|
|
16665
|
-
const scheduledMs =
|
|
16666
|
-
|
|
16667
|
-
|
|
16668
|
-
|
|
16669
|
-
|
|
16670
|
-
|
|
16671
|
-
|
|
16672
|
-
|
|
16673
|
-
|
|
16674
|
-
|
|
17246
|
+
const respectTimezone = b.respectRecipientTimezone === true;
|
|
17247
|
+
const scheduledMs = b.scheduledAt?.getTime() ?? Date.now();
|
|
17248
|
+
const cap = typeof b.recipientCap === "number" ? b.recipientCap : null;
|
|
17249
|
+
let capReached = false;
|
|
17250
|
+
try {
|
|
17251
|
+
for await (const eligible of eligibleRecipientPages(b, template.kind, ctx)) {
|
|
17252
|
+
if (!await holdsLease()) return;
|
|
17253
|
+
const bucket = await getBucketStatus(ctx, template.fromEmail, template.kind);
|
|
17254
|
+
if (bucket?.status === "tripped") {
|
|
17255
|
+
await pauseBroadcast(ctx, b._id, {
|
|
17256
|
+
code: "circuit_breaker",
|
|
17257
|
+
message: `the ${bucket.senderDomain ?? "sender"} ${template.kind} circuit breaker is tripped: ${bucket.trippedReason ?? "no reason recorded"}`,
|
|
17258
|
+
at: /* @__PURE__ */ new Date(),
|
|
17259
|
+
details: { bucket: bucket._id }
|
|
17260
|
+
});
|
|
17261
|
+
return;
|
|
17262
|
+
}
|
|
17263
|
+
const seen = await alreadyDispatched(ctx, b._id, eligible);
|
|
17264
|
+
let fresh = eligible.filter((c) => !seen.has(broadcastDedupeKey(b._id, c.externalId)));
|
|
17265
|
+
if (fresh.length === 0) continue;
|
|
17266
|
+
if (cap !== null) {
|
|
17267
|
+
const room = cap - await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
17268
|
+
if (room <= 0) {
|
|
17269
|
+
capReached = true;
|
|
17270
|
+
break;
|
|
17271
|
+
}
|
|
17272
|
+
if (fresh.length > room) {
|
|
17273
|
+
fresh = fresh.slice(0, room);
|
|
17274
|
+
capReached = true;
|
|
17275
|
+
}
|
|
17276
|
+
}
|
|
16675
17277
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16676
|
-
await
|
|
16677
|
-
{ _id: broadcast._id },
|
|
16678
|
-
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16679
|
-
);
|
|
17278
|
+
if (!await holdsLease()) return;
|
|
16680
17279
|
await sleep(2e3);
|
|
16681
17280
|
}
|
|
16682
|
-
const sendDocs = await Promise.all(
|
|
16683
|
-
eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
|
|
16684
|
-
);
|
|
16685
17281
|
const inserted = [];
|
|
16686
|
-
for (const
|
|
16687
|
-
|
|
17282
|
+
for (const contact of fresh) {
|
|
17283
|
+
const { doc, delayMs } = buildSendDoc(b, template, contact, ctx, scheduledMs, respectTimezone);
|
|
16688
17284
|
try {
|
|
16689
17285
|
await ctx.collections.sends.insertOne(doc);
|
|
16690
17286
|
inserted.push({ sendId: doc._id, delayMs });
|
|
@@ -16692,115 +17288,51 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16692
17288
|
if (err?.code !== 11e3) throw err;
|
|
16693
17289
|
}
|
|
16694
17290
|
}
|
|
16695
|
-
|
|
16696
|
-
|
|
16697
|
-
|
|
16698
|
-
|
|
16699
|
-
|
|
16700
|
-
|
|
16701
|
-
|
|
16702
|
-
|
|
16703
|
-
|
|
16704
|
-
|
|
16705
|
-
}
|
|
16706
|
-
)
|
|
17291
|
+
await Promise.all(
|
|
17292
|
+
inserted.map(
|
|
17293
|
+
({ sendId, delayMs }) => ctx.queues.send.add(
|
|
17294
|
+
"send",
|
|
17295
|
+
{ sendId: String(sendId) },
|
|
17296
|
+
{
|
|
17297
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
17298
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
17299
|
+
...delayMs > 0 ? { delay: delayMs } : {}
|
|
17300
|
+
}
|
|
16707
17301
|
)
|
|
16708
|
-
)
|
|
16709
|
-
|
|
16710
|
-
|
|
16711
|
-
}
|
|
16712
|
-
if (!page.nextCursor) break;
|
|
16713
|
-
cursor = page.nextCursor;
|
|
16714
|
-
}
|
|
16715
|
-
await ctx.collections.broadcasts.updateOne(
|
|
16716
|
-
{ _id: broadcast._id },
|
|
16717
|
-
{
|
|
16718
|
-
$set: {
|
|
16719
|
-
status: "sent",
|
|
16720
|
-
completedAt: /* @__PURE__ */ new Date(),
|
|
16721
|
-
recipientCount: total,
|
|
16722
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
16723
|
-
}
|
|
16724
|
-
}
|
|
16725
|
-
);
|
|
16726
|
-
}
|
|
16727
|
-
function toAdapterFilter(seg) {
|
|
16728
|
-
const out = {};
|
|
16729
|
-
for (const f of seg.filters) {
|
|
16730
|
-
switch (f.kind) {
|
|
16731
|
-
case "hasTag":
|
|
16732
|
-
out.hasTag = f.tag;
|
|
16733
|
-
break;
|
|
16734
|
-
case "fieldEquals":
|
|
16735
|
-
out.fieldEquals = { field: f.field, value: f.value };
|
|
16736
|
-
break;
|
|
16737
|
-
case "fieldIn":
|
|
16738
|
-
out.fieldIn = { field: f.field, values: f.values };
|
|
16739
|
-
break;
|
|
16740
|
-
case "fieldExists":
|
|
16741
|
-
out.fieldExists = f.field;
|
|
16742
|
-
break;
|
|
16743
|
-
}
|
|
16744
|
-
}
|
|
16745
|
-
return out;
|
|
16746
|
-
}
|
|
16747
|
-
function isMailerSide(f) {
|
|
16748
|
-
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";
|
|
16749
|
-
}
|
|
16750
|
-
async function applyPostFilters(contacts, filters, ctx) {
|
|
16751
|
-
if (filters.length === 0) return contacts;
|
|
16752
|
-
const externalIds = contacts.map((c) => c.externalId);
|
|
16753
|
-
const cache = {};
|
|
16754
|
-
for (const f of filters) {
|
|
16755
|
-
if (f.kind === "subscriptionStatus") {
|
|
16756
|
-
const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
|
|
16757
|
-
cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
|
|
16758
|
-
}
|
|
16759
|
-
if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
|
|
16760
|
-
const query = { externalId: { $in: externalIds }, name: f.eventName };
|
|
16761
|
-
if (f.withinDays) {
|
|
16762
|
-
query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
|
|
16763
|
-
}
|
|
16764
|
-
const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
|
|
16765
|
-
cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
|
|
17302
|
+
)
|
|
17303
|
+
);
|
|
17304
|
+
if (capReached) break;
|
|
16766
17305
|
}
|
|
16767
|
-
|
|
16768
|
-
|
|
16769
|
-
|
|
16770
|
-
|
|
16771
|
-
|
|
16772
|
-
|
|
16773
|
-
|
|
16774
|
-
|
|
16775
|
-
|
|
16776
|
-
|
|
16777
|
-
|
|
16778
|
-
|
|
16779
|
-
|
|
16780
|
-
|
|
16781
|
-
|
|
16782
|
-
|
|
16783
|
-
|
|
16784
|
-
|
|
16785
|
-
|
|
16786
|
-
|
|
16787
|
-
|
|
16788
|
-
return f.filters.some((sub) => filterMatches(c, sub, cache));
|
|
16789
|
-
case "not":
|
|
16790
|
-
return !filterMatches(c, f.filter, cache);
|
|
16791
|
-
default:
|
|
16792
|
-
return true;
|
|
17306
|
+
const now = /* @__PURE__ */ new Date();
|
|
17307
|
+
const total = await ctx.collections.sends.countDocuments({ broadcastId: b._id });
|
|
17308
|
+
const set2 = capReached ? {
|
|
17309
|
+
status: "paused",
|
|
17310
|
+
pausedAt: now,
|
|
17311
|
+
pauseReason: {
|
|
17312
|
+
code: "cap_reached",
|
|
17313
|
+
message: `${total} send(s) reached recipientCap ${cap}; eligible recipients remain \u2014 raise recipientCap and resume to send the next wave`,
|
|
17314
|
+
at: now,
|
|
17315
|
+
details: { recipientCap: cap, sendsSoFar: total }
|
|
17316
|
+
}
|
|
17317
|
+
} : { status: "sent", completedAt: now };
|
|
17318
|
+
await ctx.collections.broadcasts.updateOne(
|
|
17319
|
+
{ _id: b._id, status: "sending", dispatchLeaseId: leaseId },
|
|
17320
|
+
{ $set: { ...set2, recipientCount: total, updatedAt: now, dispatchLeaseId: null } }
|
|
17321
|
+
);
|
|
17322
|
+
} finally {
|
|
17323
|
+
await ctx.collections.broadcasts.updateOne(
|
|
17324
|
+
{ _id: b._id, dispatchLeaseId: leaseId },
|
|
17325
|
+
{ $set: { dispatchLeaseId: null } }
|
|
17326
|
+
);
|
|
16793
17327
|
}
|
|
16794
17328
|
}
|
|
16795
|
-
|
|
16796
|
-
const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
|
|
16797
|
-
if (supp.suppressed) return { doc: null, delayMs: 0 };
|
|
17329
|
+
function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
|
|
16798
17330
|
const sendId = new ObjectId();
|
|
16799
|
-
const dedupeKey =
|
|
16800
|
-
|
|
17331
|
+
const dedupeKey = broadcastDedupeKey(broadcast._id, contact.externalId);
|
|
17332
|
+
const now = Date.now();
|
|
17333
|
+
let delayMs = Math.max(0, scheduledMs - now);
|
|
16801
17334
|
if (respectTimezone && contact.timezone) {
|
|
16802
|
-
|
|
16803
|
-
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
17335
|
+
delayMs = Math.max(0, recipientSlotMs(scheduledMs, contact.timezone, now) - now);
|
|
16804
17336
|
}
|
|
16805
17337
|
const doc = {
|
|
16806
17338
|
_id: sendId,
|
|
@@ -16835,16 +17367,29 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16835
17367
|
queuedAt: /* @__PURE__ */ new Date(),
|
|
16836
17368
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
16837
17369
|
sentAt: null,
|
|
16838
|
-
deliveredAt: null
|
|
17370
|
+
deliveredAt: null,
|
|
17371
|
+
notBefore: new Date(Date.now() + delayMs)
|
|
16839
17372
|
};
|
|
16840
17373
|
return { doc, delayMs };
|
|
16841
17374
|
}
|
|
17375
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
17376
|
+
var TZ_SLOT_GRACE_MS = 15 * 60 * 1e3;
|
|
17377
|
+
function recipientSlotMs(scheduledMs, timezone, nowMs) {
|
|
17378
|
+
let anchor = scheduledMs;
|
|
17379
|
+
let target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
17380
|
+
const earliest = nowMs - TZ_SLOT_GRACE_MS;
|
|
17381
|
+
if (target < earliest) {
|
|
17382
|
+
anchor += Math.ceil((earliest - target) / DAY_MS) * DAY_MS;
|
|
17383
|
+
target = anchor + perRecipientOffsetMs(anchor, timezone);
|
|
17384
|
+
if (target < earliest) target += DAY_MS;
|
|
17385
|
+
}
|
|
17386
|
+
return target;
|
|
17387
|
+
}
|
|
16842
17388
|
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16843
|
-
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16844
17389
|
try {
|
|
16845
17390
|
const scheduled = new Date(scheduledMs);
|
|
16846
|
-
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC",
|
|
16847
|
-
const local = scheduled.toLocaleString("en-US", { timeZone: timezone,
|
|
17391
|
+
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hourCycle: "h23" });
|
|
17392
|
+
const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hourCycle: "h23" });
|
|
16848
17393
|
const parse = (s) => {
|
|
16849
17394
|
const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
|
|
16850
17395
|
if (!m) return 0;
|
|
@@ -16964,10 +17509,10 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
16964
17509
|
}
|
|
16965
17510
|
let listedCount = 0;
|
|
16966
17511
|
async function processPair(p) {
|
|
16967
|
-
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
16968
|
-
const
|
|
16969
|
-
if (
|
|
16970
|
-
if (
|
|
17512
|
+
const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
|
|
17513
|
+
const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
17514
|
+
if (lookup2.transient) return;
|
|
17515
|
+
if (lookup2.result === "listed") listedCount++;
|
|
16971
17516
|
await ctx.collections.dnsblChecks.updateOne(
|
|
16972
17517
|
{ target: p.target, list: p.list.host },
|
|
16973
17518
|
{
|
|
@@ -16976,9 +17521,9 @@ async function runDnsblChecks(ctx, opts = {}) {
|
|
|
16976
17521
|
targetKind: p.targetKind,
|
|
16977
17522
|
list: p.list.host,
|
|
16978
17523
|
listLabel: p.list.label,
|
|
16979
|
-
result:
|
|
16980
|
-
returnCodes:
|
|
16981
|
-
errorMessage:
|
|
17524
|
+
result: lookup2.result,
|
|
17525
|
+
returnCodes: lookup2.returnCodes,
|
|
17526
|
+
errorMessage: lookup2.errorMessage,
|
|
16982
17527
|
runAt: /* @__PURE__ */ new Date()
|
|
16983
17528
|
}
|
|
16984
17529
|
},
|
|
@@ -17011,6 +17556,10 @@ function collectTargets(ctx, cfg) {
|
|
|
17011
17556
|
ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
|
|
17012
17557
|
};
|
|
17013
17558
|
}
|
|
17559
|
+
function spamhausQueryHost(listHost, dqsKey) {
|
|
17560
|
+
const zone = dqsKey ? /^([a-z0-9-]+)\.spamhaus\.org$/i.exec(listHost)?.[1] : void 0;
|
|
17561
|
+
return zone ? `${dqsKey}.${zone}.dq.spamhaus.net` : listHost;
|
|
17562
|
+
}
|
|
17014
17563
|
function buildQueryName(target, kind, listHost) {
|
|
17015
17564
|
if (kind === "ip") {
|
|
17016
17565
|
const v = net.isIP(target);
|
|
@@ -17046,16 +17595,22 @@ async function queryDnsbl(resolver, query) {
|
|
|
17046
17595
|
};
|
|
17047
17596
|
}
|
|
17048
17597
|
}
|
|
17598
|
+
var REFUSAL_REASONS = {
|
|
17599
|
+
"127.255.255.252": "malformed query name for this list",
|
|
17600
|
+
"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",
|
|
17601
|
+
"127.255.255.255": "this resolver has exceeded the free query limit"
|
|
17602
|
+
};
|
|
17049
17603
|
function interpretRecords(records) {
|
|
17050
17604
|
if (!records || records.length === 0) {
|
|
17051
17605
|
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
17052
17606
|
}
|
|
17053
17607
|
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
17054
17608
|
if (errorish.length === records.length) {
|
|
17609
|
+
const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
|
|
17055
17610
|
return {
|
|
17056
17611
|
result: "error",
|
|
17057
17612
|
returnCodes: records,
|
|
17058
|
-
errorMessage: `list
|
|
17613
|
+
errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
|
|
17059
17614
|
};
|
|
17060
17615
|
}
|
|
17061
17616
|
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
@@ -17819,6 +18374,9 @@ async function runTick(ctx) {
|
|
|
17819
18374
|
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17820
18375
|
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17821
18376
|
});
|
|
18377
|
+
await evaluateActiveBroadcastStopRules(ctx).catch((err) => {
|
|
18378
|
+
console.error("mailery: broadcast stop-rule sweep failed", err);
|
|
18379
|
+
});
|
|
17822
18380
|
await evaluateHealth(ctx).catch((err) => {
|
|
17823
18381
|
console.error("mailery: health evaluation failed", err);
|
|
17824
18382
|
});
|
|
@@ -18523,6 +19081,25 @@ var Mailer = class _Mailer {
|
|
|
18523
19081
|
};
|
|
18524
19082
|
|
|
18525
19083
|
// src/testing/memory-adapter.ts
|
|
19084
|
+
function fieldPath(c, path2) {
|
|
19085
|
+
let cur = c.fields;
|
|
19086
|
+
for (const part of path2.split(".")) {
|
|
19087
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
19088
|
+
cur = cur[part];
|
|
19089
|
+
}
|
|
19090
|
+
return cur;
|
|
19091
|
+
}
|
|
19092
|
+
function compareValues(a, b) {
|
|
19093
|
+
const an = a === null || a === void 0;
|
|
19094
|
+
const bn = b === null || b === void 0;
|
|
19095
|
+
if (an || bn) return an && bn ? 0 : an ? -1 : 1;
|
|
19096
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
19097
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
19098
|
+
if (typeof av === "number" && typeof bv === "number") return av - bv;
|
|
19099
|
+
const as = String(av);
|
|
19100
|
+
const bs = String(bv);
|
|
19101
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
19102
|
+
}
|
|
18526
19103
|
var MemoryContactAdapter = class {
|
|
18527
19104
|
byId = /* @__PURE__ */ new Map();
|
|
18528
19105
|
constructor(initial = []) {
|
|
@@ -18557,8 +19134,29 @@ var MemoryContactAdapter = class {
|
|
|
18557
19134
|
}
|
|
18558
19135
|
return out;
|
|
18559
19136
|
}
|
|
19137
|
+
/** `query` honours `opts.sort` over `contact.fields` (dotted paths), like MongoContactAdapter over documents. */
|
|
19138
|
+
supportsSort = true;
|
|
18560
19139
|
async query(filter, opts) {
|
|
18561
|
-
|
|
19140
|
+
if (opts.sort) {
|
|
19141
|
+
const { field, direction } = opts.sort;
|
|
19142
|
+
const dir = direction === "desc" ? -1 : 1;
|
|
19143
|
+
const sorted = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => {
|
|
19144
|
+
const byValue = compareValues(fieldPath(a, field), fieldPath(b, field)) * dir;
|
|
19145
|
+
if (byValue !== 0) return byValue;
|
|
19146
|
+
return a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0;
|
|
19147
|
+
});
|
|
19148
|
+
let start = 0;
|
|
19149
|
+
if (opts.cursor) {
|
|
19150
|
+
const at = sorted.findIndex((c) => c.externalId === opts.cursor);
|
|
19151
|
+
start = at === -1 ? sorted.length : at + 1;
|
|
19152
|
+
}
|
|
19153
|
+
const page = sorted.slice(start, start + opts.limit);
|
|
19154
|
+
return {
|
|
19155
|
+
contacts: page,
|
|
19156
|
+
nextCursor: start + opts.limit < sorted.length ? page[page.length - 1]?.externalId : void 0
|
|
19157
|
+
};
|
|
19158
|
+
}
|
|
19159
|
+
const all = Array.from(this.byId.values()).filter((c) => matches2(c, filter)).sort((a, b) => a.externalId < b.externalId ? -1 : 1);
|
|
18562
19160
|
const startIndex = opts.cursor ? all.findIndex((c) => c.externalId > opts.cursor) : 0;
|
|
18563
19161
|
const slice = all.slice(startIndex === -1 ? all.length : startIndex, (startIndex === -1 ? all.length : startIndex) + opts.limit);
|
|
18564
19162
|
const hasMore = startIndex !== -1 && startIndex + opts.limit < all.length;
|
|
@@ -18569,15 +19167,15 @@ var MemoryContactAdapter = class {
|
|
|
18569
19167
|
}
|
|
18570
19168
|
async count(filter) {
|
|
18571
19169
|
let n = 0;
|
|
18572
|
-
for (const c of this.byId.values()) if (
|
|
19170
|
+
for (const c of this.byId.values()) if (matches2(c, filter)) n++;
|
|
18573
19171
|
return n;
|
|
18574
19172
|
}
|
|
18575
19173
|
async addTags(externalId, tags) {
|
|
18576
19174
|
const c = this.byId.get(externalId);
|
|
18577
19175
|
if (!c) return;
|
|
18578
|
-
const
|
|
18579
|
-
for (const t of tags)
|
|
18580
|
-
c.tags = Array.from(
|
|
19176
|
+
const set2 = new Set(c.tags);
|
|
19177
|
+
for (const t of tags) set2.add(t);
|
|
19178
|
+
c.tags = Array.from(set2);
|
|
18581
19179
|
}
|
|
18582
19180
|
async removeTags(externalId, tags) {
|
|
18583
19181
|
const c = this.byId.get(externalId);
|
|
@@ -18586,7 +19184,7 @@ var MemoryContactAdapter = class {
|
|
|
18586
19184
|
c.tags = c.tags.filter((t) => !remove.has(t));
|
|
18587
19185
|
}
|
|
18588
19186
|
};
|
|
18589
|
-
function
|
|
19187
|
+
function matches2(c, f) {
|
|
18590
19188
|
if (f.emailIn && !f.emailIn.map((e) => e.toLowerCase()).includes(c.email)) return false;
|
|
18591
19189
|
if (f.externalIdIn && !f.externalIdIn.includes(c.externalId)) return false;
|
|
18592
19190
|
if (f.fieldEquals && c.fields[f.fieldEquals.field] !== f.fieldEquals.value) return false;
|