mailery 0.3.2 → 0.5.1
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-Dmsla2eL.js +41 -0
- package/dist/admin/spa/index-Dmsla2eL.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/template-editor-Bn28OPMU.js +502 -0
- package/dist/admin/spa/{template-editor-dMzrGcva.js.map → template-editor-Bn28OPMU.js.map} +1 -1
- package/dist/cli.cjs +231 -56
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +231 -56
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +2733 -222
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -2
- package/dist/index.d.ts +38 -2
- package/dist/index.js +2729 -222
- package/dist/index.js.map +1 -1
- package/dist/{null-CGaNDDlQ.d.cts → null-BYB6v4Iv.d.cts} +324 -2
- package/dist/{null-CGaNDDlQ.d.ts → null-BYB6v4Iv.d.ts} +324 -2
- package/dist/testing.cjs +894 -94
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +891 -94
- package/dist/testing.js.map +1 -1
- package/package.json +8 -1
- package/dist/admin/spa/index-B1Mp8lSz.js +0 -41
- package/dist/admin/spa/index-B1Mp8lSz.js.map +0 -1
- package/dist/admin/spa/template-editor-dMzrGcva.js +0 -502
package/dist/testing.js
CHANGED
|
@@ -6,6 +6,9 @@ import IORedis from 'ioredis';
|
|
|
6
6
|
import Handlebars from 'handlebars';
|
|
7
7
|
import { convert } from 'html-to-text';
|
|
8
8
|
import mjml2html from 'mjml';
|
|
9
|
+
import dns from 'dns/promises';
|
|
10
|
+
import net from 'net';
|
|
11
|
+
import psl from 'psl';
|
|
9
12
|
|
|
10
13
|
var __create = Object.create;
|
|
11
14
|
var __defProp = Object.defineProperty;
|
|
@@ -4155,7 +4158,7 @@ var require_getport = __commonJS({
|
|
|
4155
4158
|
exports.tryPort = tryPort;
|
|
4156
4159
|
exports.resetPortsCache = resetPortsCache;
|
|
4157
4160
|
var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
|
|
4158
|
-
var
|
|
4161
|
+
var net2 = tslib_1.__importStar(__require("net"));
|
|
4159
4162
|
var debug_1 = tslib_1.__importDefault(require_src());
|
|
4160
4163
|
var log = (0, debug_1.default)("MongoMS:GetPort");
|
|
4161
4164
|
exports.MIN_PORT = 1024;
|
|
@@ -4203,7 +4206,7 @@ var require_getport = __commonJS({
|
|
|
4203
4206
|
}
|
|
4204
4207
|
function tryPort(port) {
|
|
4205
4208
|
return new Promise((res, rej) => {
|
|
4206
|
-
const server =
|
|
4209
|
+
const server = net2.createServer();
|
|
4207
4210
|
if (typeof server.unref === "function") {
|
|
4208
4211
|
server.unref();
|
|
4209
4212
|
}
|
|
@@ -9169,7 +9172,7 @@ var require_dist = __commonJS({
|
|
|
9169
9172
|
};
|
|
9170
9173
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9171
9174
|
exports.Agent = void 0;
|
|
9172
|
-
var
|
|
9175
|
+
var net2 = __importStar2(__require("net"));
|
|
9173
9176
|
var http = __importStar2(__require("http"));
|
|
9174
9177
|
var https_1 = __require("https");
|
|
9175
9178
|
__exportStar2(require_helpers(), exports);
|
|
@@ -9209,7 +9212,7 @@ var require_dist = __commonJS({
|
|
|
9209
9212
|
if (!this.sockets[name]) {
|
|
9210
9213
|
this.sockets[name] = [];
|
|
9211
9214
|
}
|
|
9212
|
-
const fakeSocket = new
|
|
9215
|
+
const fakeSocket = new net2.Socket({ writable: false });
|
|
9213
9216
|
this.sockets[name].push(fakeSocket);
|
|
9214
9217
|
this.totalSocketCount++;
|
|
9215
9218
|
return fakeSocket;
|
|
@@ -9419,7 +9422,7 @@ var require_dist2 = __commonJS({
|
|
|
9419
9422
|
};
|
|
9420
9423
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9421
9424
|
exports.HttpsProxyAgent = void 0;
|
|
9422
|
-
var
|
|
9425
|
+
var net2 = __importStar2(__require("net"));
|
|
9423
9426
|
var tls = __importStar2(__require("tls"));
|
|
9424
9427
|
var assert_1 = __importDefault2(__require("assert"));
|
|
9425
9428
|
var debug_1 = __importDefault2(require_src());
|
|
@@ -9428,7 +9431,7 @@ var require_dist2 = __commonJS({
|
|
|
9428
9431
|
var parse_proxy_response_1 = require_parse_proxy_response();
|
|
9429
9432
|
var debug = (0, debug_1.default)("https-proxy-agent");
|
|
9430
9433
|
var setServernameFromNonIpHost = (options2) => {
|
|
9431
|
-
if (options2.servername === void 0 && options2.host && !
|
|
9434
|
+
if (options2.servername === void 0 && options2.host && !net2.isIP(options2.host)) {
|
|
9432
9435
|
return {
|
|
9433
9436
|
...options2,
|
|
9434
9437
|
servername: options2.host
|
|
@@ -9468,10 +9471,10 @@ var require_dist2 = __commonJS({
|
|
|
9468
9471
|
socket = tls.connect(setServernameFromNonIpHost(this.connectOpts));
|
|
9469
9472
|
} else {
|
|
9470
9473
|
debug("Creating `net.Socket`: %o", this.connectOpts);
|
|
9471
|
-
socket =
|
|
9474
|
+
socket = net2.connect(this.connectOpts);
|
|
9472
9475
|
}
|
|
9473
9476
|
const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders };
|
|
9474
|
-
const host =
|
|
9477
|
+
const host = net2.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
|
|
9475
9478
|
let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r
|
|
9476
9479
|
`;
|
|
9477
9480
|
if (proxy.username || proxy.password) {
|
|
@@ -9504,7 +9507,7 @@ var require_dist2 = __commonJS({
|
|
|
9504
9507
|
return socket;
|
|
9505
9508
|
}
|
|
9506
9509
|
socket.destroy();
|
|
9507
|
-
const fakeSocket = new
|
|
9510
|
+
const fakeSocket = new net2.Socket({ writable: false });
|
|
9508
9511
|
fakeSocket.readable = true;
|
|
9509
9512
|
req.once("socket", (s) => {
|
|
9510
9513
|
debug("Replaying proxy buffer for failed request");
|
|
@@ -14253,6 +14256,17 @@ var predicateSchema = z.lazy(
|
|
|
14253
14256
|
);
|
|
14254
14257
|
|
|
14255
14258
|
// src/server/config.ts
|
|
14259
|
+
var DEFAULT_DOMAIN_DNSBL_LISTS = [
|
|
14260
|
+
{ host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
|
|
14261
|
+
{ host: "multi.surbl.org", label: "SURBL" },
|
|
14262
|
+
{ host: "multi.uribl.com", label: "URIBL" }
|
|
14263
|
+
];
|
|
14264
|
+
var DEFAULT_IP_DNSBL_LISTS = [
|
|
14265
|
+
{ host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
|
|
14266
|
+
{ host: "b.barracudacentral.org", label: "Barracuda" },
|
|
14267
|
+
{ host: "dnsbl.sorbs.net", label: "SORBS" },
|
|
14268
|
+
{ host: "bl.spamcop.net", label: "SpamCop" }
|
|
14269
|
+
];
|
|
14256
14270
|
var DEFAULTS = {
|
|
14257
14271
|
collectionPrefix: "mailer_",
|
|
14258
14272
|
requireDoubleOptIn: false,
|
|
@@ -14313,6 +14327,10 @@ function resolveConfig(c) {
|
|
|
14313
14327
|
}
|
|
14314
14328
|
|
|
14315
14329
|
// src/server/models/index.ts
|
|
14330
|
+
var HEALTH_AGG_ID = "agg";
|
|
14331
|
+
function healthBucketId(senderDomain, kind) {
|
|
14332
|
+
return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
|
|
14333
|
+
}
|
|
14316
14334
|
function getCollections(db, prefix = "mailer_") {
|
|
14317
14335
|
return {
|
|
14318
14336
|
subscriptions: db.collection(`${prefix}subscriptions`),
|
|
@@ -14330,7 +14348,14 @@ function getCollections(db, prefix = "mailer_") {
|
|
|
14330
14348
|
auditLog: db.collection(`${prefix}audit_log`),
|
|
14331
14349
|
webhookEvents: db.collection(`${prefix}webhook_events`),
|
|
14332
14350
|
health: db.collection(`${prefix}health`),
|
|
14333
|
-
contactTags: db.collection(`${prefix}contact_tags`)
|
|
14351
|
+
contactTags: db.collection(`${prefix}contact_tags`),
|
|
14352
|
+
dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
|
|
14353
|
+
postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
|
|
14354
|
+
sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
|
|
14355
|
+
dmarcReports: db.collection(`${prefix}dmarc_reports`),
|
|
14356
|
+
dmarcFailures: db.collection(`${prefix}dmarc_failures`),
|
|
14357
|
+
dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
|
|
14358
|
+
mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
|
|
14334
14359
|
};
|
|
14335
14360
|
}
|
|
14336
14361
|
async function ensureIndexes(db, prefix = "mailer_") {
|
|
@@ -14402,8 +14427,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14402
14427
|
c.contactTags.createIndexes([
|
|
14403
14428
|
{ key: { externalId: 1, tag: 1 }, unique: true },
|
|
14404
14429
|
{ key: { tag: 1 } }
|
|
14430
|
+
]),
|
|
14431
|
+
c.health.createIndexes([
|
|
14432
|
+
{ key: { senderDomain: 1, kind: 1 } },
|
|
14433
|
+
{ key: { status: 1 } },
|
|
14434
|
+
// setup-status reads the most-recently-touched health doc as a heartbeat.
|
|
14435
|
+
{ key: { updatedAt: -1 } }
|
|
14436
|
+
]),
|
|
14437
|
+
c.dnsblChecks.createIndexes([
|
|
14438
|
+
{ key: { target: 1, list: 1 }, unique: true },
|
|
14439
|
+
// Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
|
|
14440
|
+
{ key: { result: 1, target: 1, list: 1 } },
|
|
14441
|
+
// TTL — stale rows for targets the operator removed disappear after
|
|
14442
|
+
// 60 days without manual cleanup. The puller refreshes runAt on
|
|
14443
|
+
// every active target so existing targets stay indefinitely.
|
|
14444
|
+
{ key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
|
|
14445
|
+
]),
|
|
14446
|
+
c.postmasterSnapshots.createIndexes([
|
|
14447
|
+
{ key: { domain: 1, date: 1 }, unique: true },
|
|
14448
|
+
{ key: { domain: 1, fetchedAt: -1 } },
|
|
14449
|
+
{ key: { domainReputation: 1 } }
|
|
14450
|
+
]),
|
|
14451
|
+
c.sndsSnapshots.createIndexes([
|
|
14452
|
+
{ key: { ip: 1, activityStart: 1 }, unique: true },
|
|
14453
|
+
{ key: { ip: 1, fetchedAt: -1 } },
|
|
14454
|
+
{ key: { filterResult: 1 } }
|
|
14455
|
+
]),
|
|
14456
|
+
c.dmarcReports.createIndexes([
|
|
14457
|
+
{ key: { reportId: 1, orgName: 1 }, unique: true },
|
|
14458
|
+
{ key: { domain: 1, rangeEnd: -1 } },
|
|
14459
|
+
// Cross-domain "most recent reports" queries scan a lot without this.
|
|
14460
|
+
{ key: { rangeEnd: -1 } },
|
|
14461
|
+
{ key: { receivedAt: -1 } }
|
|
14462
|
+
]),
|
|
14463
|
+
c.dmarcFailures.createIndexes([
|
|
14464
|
+
{ key: { reportId: 1, sourceIp: 1 }, unique: true },
|
|
14465
|
+
{ key: { domain: 1, day: -1 } },
|
|
14466
|
+
{ key: { sourceIp: 1, day: -1 } },
|
|
14467
|
+
{ key: { receivedAt: 1 } }
|
|
14468
|
+
// for retention pruning
|
|
14469
|
+
]),
|
|
14470
|
+
c.dmarcSourceTags.createIndexes([
|
|
14471
|
+
{ key: { ip: 1 }, unique: true }
|
|
14472
|
+
]),
|
|
14473
|
+
c.mailTesterScores.createIndexes([
|
|
14474
|
+
{ key: { contentKey: 1 }, unique: true },
|
|
14475
|
+
{ key: { templateSlug: 1, fetchedAt: -1 } },
|
|
14476
|
+
// TTL — Mongo auto-deletes expired scores so we never serve stale data.
|
|
14477
|
+
{ key: { expiresAt: 1 }, expireAfterSeconds: 0 }
|
|
14405
14478
|
])
|
|
14406
14479
|
]);
|
|
14480
|
+
await c.health.deleteOne({ _id: "singleton" }).catch(() => {
|
|
14481
|
+
});
|
|
14407
14482
|
}
|
|
14408
14483
|
var EventRegistry = class {
|
|
14409
14484
|
policies = /* @__PURE__ */ new Map();
|
|
@@ -14416,6 +14491,9 @@ var EventRegistry = class {
|
|
|
14416
14491
|
policy(name) {
|
|
14417
14492
|
return this.policies.get(name);
|
|
14418
14493
|
}
|
|
14494
|
+
list() {
|
|
14495
|
+
return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
|
|
14496
|
+
}
|
|
14419
14497
|
/**
|
|
14420
14498
|
* Derive a dedupeKey for an event call. Returns null when no policy is
|
|
14421
14499
|
* registered AND no key was passed — caller should throw.
|
|
@@ -15065,21 +15143,59 @@ async function isSuppressed(collections, email, kind) {
|
|
|
15065
15143
|
return { suppressed: false };
|
|
15066
15144
|
}
|
|
15067
15145
|
|
|
15146
|
+
// src/server/templates/sender-domain.ts
|
|
15147
|
+
function extractDomain(email) {
|
|
15148
|
+
if (typeof email !== "string") return null;
|
|
15149
|
+
const at = email.lastIndexOf("@");
|
|
15150
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15151
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15152
|
+
}
|
|
15153
|
+
|
|
15068
15154
|
// src/server/runner/health.ts
|
|
15069
|
-
|
|
15155
|
+
var ZERO_COUNTERS = {
|
|
15156
|
+
sent: 0,
|
|
15157
|
+
delivered: 0,
|
|
15158
|
+
bounced: 0,
|
|
15159
|
+
hardBounced: 0,
|
|
15160
|
+
softBounced: 0,
|
|
15161
|
+
complained: 0,
|
|
15162
|
+
failedToSend: 0
|
|
15163
|
+
};
|
|
15164
|
+
var ZERO_RATES = {
|
|
15165
|
+
bounceRate: 0,
|
|
15166
|
+
hardBounceRate: 0,
|
|
15167
|
+
complaintRate: 0,
|
|
15168
|
+
failureRate: 0
|
|
15169
|
+
};
|
|
15170
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15171
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15172
|
+
const writes = [];
|
|
15173
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15174
|
+
if (dims) {
|
|
15175
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15176
|
+
if (domain) {
|
|
15177
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15178
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15179
|
+
}
|
|
15180
|
+
}
|
|
15181
|
+
await Promise.all(writes);
|
|
15182
|
+
}
|
|
15183
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15070
15184
|
await ctx.collections.health.updateOne(
|
|
15071
|
-
{ _id
|
|
15185
|
+
{ _id },
|
|
15072
15186
|
{
|
|
15073
15187
|
$inc: { [`counters.${counter2}`]: by },
|
|
15074
15188
|
$setOnInsert: {
|
|
15075
|
-
_id
|
|
15189
|
+
_id,
|
|
15190
|
+
senderDomain,
|
|
15191
|
+
kind,
|
|
15076
15192
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15077
|
-
windowDurationMs:
|
|
15193
|
+
windowDurationMs: windowMs,
|
|
15078
15194
|
status: "healthy",
|
|
15079
15195
|
trippedAt: null,
|
|
15080
15196
|
trippedReason: null,
|
|
15081
15197
|
manuallyResumedAt: null,
|
|
15082
|
-
rates: {
|
|
15198
|
+
rates: { ...ZERO_RATES }
|
|
15083
15199
|
},
|
|
15084
15200
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15085
15201
|
},
|
|
@@ -15089,73 +15205,107 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
|
|
|
15089
15205
|
async function evaluateHealth(ctx) {
|
|
15090
15206
|
const cb = ctx.config.circuitBreaker;
|
|
15091
15207
|
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15092
|
-
const
|
|
15093
|
-
if (
|
|
15094
|
-
const
|
|
15095
|
-
if (
|
|
15096
|
-
await ctx
|
|
15097
|
-
{ _id: "singleton" },
|
|
15098
|
-
{
|
|
15099
|
-
$set: {
|
|
15100
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15101
|
-
windowDurationMs: windowMs,
|
|
15102
|
-
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
15103
|
-
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 },
|
|
15104
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
15105
|
-
}
|
|
15106
|
-
}
|
|
15107
|
-
);
|
|
15108
|
-
return;
|
|
15208
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15209
|
+
if (docs.length === 0) return;
|
|
15210
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15211
|
+
if (!hasAgg) {
|
|
15212
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15109
15213
|
}
|
|
15110
|
-
const
|
|
15111
|
-
|
|
15112
|
-
|
|
15113
|
-
|
|
15114
|
-
|
|
15115
|
-
|
|
15116
|
-
|
|
15117
|
-
|
|
15118
|
-
|
|
15119
|
-
|
|
15120
|
-
|
|
15121
|
-
|
|
15122
|
-
|
|
15123
|
-
|
|
15124
|
-
|
|
15125
|
-
|
|
15126
|
-
|
|
15127
|
-
|
|
15128
|
-
|
|
15129
|
-
|
|
15130
|
-
|
|
15131
|
-
|
|
15132
|
-
|
|
15214
|
+
for (const doc of docs) {
|
|
15215
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15216
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15217
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15218
|
+
await ctx.collections.health.updateOne(
|
|
15219
|
+
{ _id: doc._id },
|
|
15220
|
+
{
|
|
15221
|
+
$set: {
|
|
15222
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15223
|
+
windowDurationMs: windowMs,
|
|
15224
|
+
counters: { ...ZERO_COUNTERS },
|
|
15225
|
+
rates: { ...ZERO_RATES },
|
|
15226
|
+
status: "healthy",
|
|
15227
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15228
|
+
}
|
|
15229
|
+
}
|
|
15230
|
+
);
|
|
15231
|
+
continue;
|
|
15232
|
+
}
|
|
15233
|
+
const c = doc.counters;
|
|
15234
|
+
const total = c.sent || 1;
|
|
15235
|
+
const rates = {
|
|
15236
|
+
bounceRate: c.bounced / total,
|
|
15237
|
+
hardBounceRate: c.hardBounced / total,
|
|
15238
|
+
complaintRate: c.complained / total,
|
|
15239
|
+
failureRate: c.failedToSend / total
|
|
15240
|
+
};
|
|
15133
15241
|
await ctx.collections.health.updateOne(
|
|
15134
|
-
{ _id:
|
|
15135
|
-
{ $set: {
|
|
15242
|
+
{ _id: doc._id },
|
|
15243
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15136
15244
|
);
|
|
15137
|
-
if (
|
|
15138
|
-
|
|
15139
|
-
|
|
15140
|
-
|
|
15245
|
+
if (isAgg) continue;
|
|
15246
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15247
|
+
if (doc.status === "tripped") continue;
|
|
15248
|
+
let trippedReason = null;
|
|
15249
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15250
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15251
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15252
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15253
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15254
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15255
|
+
}
|
|
15256
|
+
if (trippedReason) {
|
|
15257
|
+
const result = await ctx.collections.health.updateOne(
|
|
15258
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15259
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15260
|
+
);
|
|
15261
|
+
if (result.modifiedCount > 0) {
|
|
15262
|
+
if (ctx.audit) {
|
|
15263
|
+
try {
|
|
15264
|
+
await ctx.audit({
|
|
15265
|
+
actor: "system:circuit-breaker",
|
|
15266
|
+
action: "health.trip",
|
|
15267
|
+
resource: {
|
|
15268
|
+
collection: "mailer_health",
|
|
15269
|
+
id: String(doc._id),
|
|
15270
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15271
|
+
},
|
|
15272
|
+
diffSummary: trippedReason
|
|
15273
|
+
});
|
|
15274
|
+
} catch {
|
|
15275
|
+
}
|
|
15276
|
+
}
|
|
15277
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15278
|
+
try {
|
|
15279
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15280
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15281
|
+
rates
|
|
15282
|
+
});
|
|
15283
|
+
} catch {
|
|
15284
|
+
}
|
|
15285
|
+
}
|
|
15141
15286
|
}
|
|
15287
|
+
continue;
|
|
15142
15288
|
}
|
|
15143
|
-
|
|
15144
|
-
|
|
15145
|
-
|
|
15146
|
-
|
|
15289
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15290
|
+
if (doc.status !== "degraded") {
|
|
15291
|
+
await ctx.collections.health.updateOne(
|
|
15292
|
+
{ _id: doc._id },
|
|
15293
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15294
|
+
);
|
|
15295
|
+
}
|
|
15296
|
+
} else if (doc.status === "degraded") {
|
|
15147
15297
|
await ctx.collections.health.updateOne(
|
|
15148
|
-
{ _id:
|
|
15149
|
-
{ $set: { status: "
|
|
15298
|
+
{ _id: doc._id },
|
|
15299
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15150
15300
|
);
|
|
15151
15301
|
}
|
|
15152
|
-
} else if (doc.status === "degraded") {
|
|
15153
|
-
await ctx.collections.health.updateOne(
|
|
15154
|
-
{ _id: "singleton" },
|
|
15155
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15156
|
-
);
|
|
15157
15302
|
}
|
|
15158
15303
|
}
|
|
15304
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15305
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15306
|
+
const id = healthBucketId(domain, kind);
|
|
15307
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15308
|
+
}
|
|
15159
15309
|
|
|
15160
15310
|
// src/server/runner/send.ts
|
|
15161
15311
|
async function handleSend(run, step, contact, flow, ctx) {
|
|
@@ -15239,8 +15389,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15239
15389
|
return;
|
|
15240
15390
|
}
|
|
15241
15391
|
if (send.kind === "marketing") {
|
|
15242
|
-
const
|
|
15243
|
-
if (
|
|
15392
|
+
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15393
|
+
if (bucket?.status === "tripped") {
|
|
15244
15394
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15245
15395
|
return;
|
|
15246
15396
|
}
|
|
@@ -15309,13 +15459,13 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15309
15459
|
}
|
|
15310
15460
|
}
|
|
15311
15461
|
);
|
|
15312
|
-
await recordHealthCounter(ctx, "sent");
|
|
15462
|
+
await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
|
|
15313
15463
|
} catch (err) {
|
|
15314
15464
|
await ctx.collections.sends.updateOne(
|
|
15315
15465
|
{ _id: send._id },
|
|
15316
15466
|
{ $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
|
|
15317
15467
|
);
|
|
15318
|
-
await recordHealthCounter(ctx, "failedToSend");
|
|
15468
|
+
await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
|
|
15319
15469
|
if (ctx.config.onSendFailure) {
|
|
15320
15470
|
try {
|
|
15321
15471
|
await ctx.config.onSendFailure({ send, error: err });
|
|
@@ -15899,7 +16049,15 @@ async function promoteSoftBounces(ctx) {
|
|
|
15899
16049
|
const cutoff = new Date(Date.now() - windowDays * 864e5);
|
|
15900
16050
|
const offenders = await ctx.collections.sends.aggregate([
|
|
15901
16051
|
{ $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
|
|
15902
|
-
{ $
|
|
16052
|
+
{ $sort: { queuedAt: 1 } },
|
|
16053
|
+
{
|
|
16054
|
+
$group: {
|
|
16055
|
+
_id: "$emailAtSend",
|
|
16056
|
+
count: { $sum: 1 },
|
|
16057
|
+
lastFromEmail: { $last: "$fromEmail" },
|
|
16058
|
+
lastKind: { $last: "$kind" }
|
|
16059
|
+
}
|
|
16060
|
+
},
|
|
15903
16061
|
{ $match: { count: { $gte: threshold } } },
|
|
15904
16062
|
{ $limit: 200 }
|
|
15905
16063
|
]).toArray();
|
|
@@ -15931,32 +16089,649 @@ async function promoteSoftBounces(ctx) {
|
|
|
15931
16089
|
{ emailAtSubscribe: email },
|
|
15932
16090
|
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15933
16091
|
);
|
|
15934
|
-
await recordHealthCounter(
|
|
16092
|
+
await recordHealthCounter(
|
|
16093
|
+
ctx,
|
|
16094
|
+
"hardBounced",
|
|
16095
|
+
o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
|
|
16096
|
+
);
|
|
16097
|
+
}
|
|
16098
|
+
}
|
|
16099
|
+
var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
|
|
16100
|
+
var DNS_CONCURRENCY = 8;
|
|
16101
|
+
var defaultResolver = {
|
|
16102
|
+
resolve4: (hostname) => dns.resolve4(hostname)
|
|
16103
|
+
};
|
|
16104
|
+
async function runDnsblChecks(ctx, opts = {}) {
|
|
16105
|
+
const cfg = ctx.config.dnsbl ?? {};
|
|
16106
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
16107
|
+
if (!opts.force && intervalHours <= 0) {
|
|
16108
|
+
return { ran: false, reason: "disabled" };
|
|
16109
|
+
}
|
|
16110
|
+
const targets = collectTargets(ctx, cfg);
|
|
16111
|
+
if (targets.domains.length === 0 && targets.ips.length === 0) {
|
|
16112
|
+
return { ran: false, reason: "no_targets" };
|
|
16113
|
+
}
|
|
16114
|
+
const resolver = opts.resolver ?? defaultResolver;
|
|
16115
|
+
const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
|
|
16116
|
+
const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
|
|
16117
|
+
const pairs = [];
|
|
16118
|
+
for (const d of targets.domains) {
|
|
16119
|
+
for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
|
|
16120
|
+
}
|
|
16121
|
+
for (const ip of targets.ips) {
|
|
16122
|
+
for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
|
|
16123
|
+
}
|
|
16124
|
+
const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
|
|
16125
|
+
let duePairs = pairs;
|
|
16126
|
+
if (throttleCutoff != null) {
|
|
16127
|
+
const existing = await ctx.collections.dnsblChecks.find(
|
|
16128
|
+
{
|
|
16129
|
+
$or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
|
|
16130
|
+
},
|
|
16131
|
+
{ projection: { target: 1, list: 1, runAt: 1 } }
|
|
16132
|
+
).toArray();
|
|
16133
|
+
const fresh = /* @__PURE__ */ new Set();
|
|
16134
|
+
for (const e of existing) {
|
|
16135
|
+
if (new Date(e.runAt).getTime() > throttleCutoff) {
|
|
16136
|
+
fresh.add(`${e.target}|${e.list}`);
|
|
16137
|
+
}
|
|
16138
|
+
}
|
|
16139
|
+
duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
|
|
16140
|
+
if (duePairs.length === 0) {
|
|
16141
|
+
return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
|
|
16142
|
+
}
|
|
16143
|
+
}
|
|
16144
|
+
let listedCount = 0;
|
|
16145
|
+
async function processPair(p) {
|
|
16146
|
+
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
16147
|
+
const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
16148
|
+
if (lookup.transient) return;
|
|
16149
|
+
if (lookup.result === "listed") listedCount++;
|
|
16150
|
+
await ctx.collections.dnsblChecks.updateOne(
|
|
16151
|
+
{ target: p.target, list: p.list.host },
|
|
16152
|
+
{
|
|
16153
|
+
$set: {
|
|
16154
|
+
target: p.target,
|
|
16155
|
+
targetKind: p.targetKind,
|
|
16156
|
+
list: p.list.host,
|
|
16157
|
+
listLabel: p.list.label,
|
|
16158
|
+
result: lookup.result,
|
|
16159
|
+
returnCodes: lookup.returnCodes,
|
|
16160
|
+
errorMessage: lookup.errorMessage,
|
|
16161
|
+
runAt: /* @__PURE__ */ new Date()
|
|
16162
|
+
}
|
|
16163
|
+
},
|
|
16164
|
+
{ upsert: true }
|
|
16165
|
+
);
|
|
16166
|
+
}
|
|
16167
|
+
let idx = 0;
|
|
16168
|
+
await Promise.all(
|
|
16169
|
+
Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
|
|
16170
|
+
while (idx < duePairs.length) {
|
|
16171
|
+
const my = idx++;
|
|
16172
|
+
await processPair(duePairs[my]);
|
|
16173
|
+
}
|
|
16174
|
+
})
|
|
16175
|
+
);
|
|
16176
|
+
return { ran: true, totalChecks: duePairs.length, listedCount };
|
|
16177
|
+
}
|
|
16178
|
+
function collectTargets(ctx, cfg) {
|
|
16179
|
+
const domains = /* @__PURE__ */ new Set();
|
|
16180
|
+
const registry = ctx.config.senderDomains;
|
|
16181
|
+
if (registry) {
|
|
16182
|
+
for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
|
|
16183
|
+
}
|
|
16184
|
+
const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
|
|
16185
|
+
if (fromDomain) domains.add(fromDomain);
|
|
16186
|
+
const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
|
|
16187
|
+
if (txnDomain) domains.add(txnDomain);
|
|
16188
|
+
return {
|
|
16189
|
+
domains: Array.from(domains),
|
|
16190
|
+
ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
|
|
16191
|
+
};
|
|
16192
|
+
}
|
|
16193
|
+
function buildQueryName(target, kind, listHost) {
|
|
16194
|
+
if (kind === "ip") {
|
|
16195
|
+
const v = net.isIP(target);
|
|
16196
|
+
if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
|
|
16197
|
+
if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
|
|
16198
|
+
return null;
|
|
15935
16199
|
}
|
|
16200
|
+
const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
|
|
16201
|
+
return `${domain}.${listHost}`;
|
|
16202
|
+
}
|
|
16203
|
+
var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
|
|
16204
|
+
async function queryDnsbl(resolver, query) {
|
|
16205
|
+
try {
|
|
16206
|
+
const records = await resolver.resolve4(query);
|
|
16207
|
+
return interpretRecords(records);
|
|
16208
|
+
} catch (err) {
|
|
16209
|
+
const code = err?.code;
|
|
16210
|
+
if (code === "ENOTFOUND" || code === "ENODATA") {
|
|
16211
|
+
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
16212
|
+
}
|
|
16213
|
+
if (code && TRANSIENT_DNS_CODES.has(code)) {
|
|
16214
|
+
return {
|
|
16215
|
+
transient: true,
|
|
16216
|
+
result: "error",
|
|
16217
|
+
returnCodes: [],
|
|
16218
|
+
errorMessage: String(err?.message ?? err)
|
|
16219
|
+
};
|
|
16220
|
+
}
|
|
16221
|
+
return {
|
|
16222
|
+
result: "error",
|
|
16223
|
+
returnCodes: [],
|
|
16224
|
+
errorMessage: String(err?.message ?? err)
|
|
16225
|
+
};
|
|
16226
|
+
}
|
|
16227
|
+
}
|
|
16228
|
+
function interpretRecords(records) {
|
|
16229
|
+
if (!records || records.length === 0) {
|
|
16230
|
+
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
16231
|
+
}
|
|
16232
|
+
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
16233
|
+
if (errorish.length === records.length) {
|
|
16234
|
+
return {
|
|
16235
|
+
result: "error",
|
|
16236
|
+
returnCodes: records,
|
|
16237
|
+
errorMessage: `list returned reserved code(s): ${records.join(", ")}`
|
|
16238
|
+
};
|
|
16239
|
+
}
|
|
16240
|
+
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
16241
|
+
if (listed.length > 0) {
|
|
16242
|
+
return { result: "listed", returnCodes: records, errorMessage: null };
|
|
16243
|
+
}
|
|
16244
|
+
return { result: "clean", returnCodes: records, errorMessage: null };
|
|
16245
|
+
}
|
|
16246
|
+
function reverseIPv4(ip) {
|
|
16247
|
+
return ip.split(".").reverse().join(".");
|
|
16248
|
+
}
|
|
16249
|
+
function reverseIPv6Nibbles(ip) {
|
|
16250
|
+
const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
|
|
16251
|
+
let normalized = ip;
|
|
16252
|
+
if (v4Match) {
|
|
16253
|
+
const octets = v4Match[1].split(".").map((o) => Number(o));
|
|
16254
|
+
if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
|
|
16255
|
+
const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
|
|
16256
|
+
const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
|
|
16257
|
+
normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
|
|
16258
|
+
}
|
|
16259
|
+
}
|
|
16260
|
+
const sides = normalized.split("::");
|
|
16261
|
+
let groups;
|
|
16262
|
+
if (sides.length === 1) {
|
|
16263
|
+
groups = sides[0].split(":");
|
|
16264
|
+
} else {
|
|
16265
|
+
const left = sides[0].split(":").filter(Boolean);
|
|
16266
|
+
const right = sides[1].split(":").filter(Boolean);
|
|
16267
|
+
const missing = 8 - left.length - right.length;
|
|
16268
|
+
groups = [...left, ...Array(missing).fill("0"), ...right];
|
|
16269
|
+
}
|
|
16270
|
+
if (groups.length !== 8) {
|
|
16271
|
+
throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
|
|
16272
|
+
}
|
|
16273
|
+
const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
|
|
16274
|
+
return padded.split("").reverse().join(".");
|
|
16275
|
+
}
|
|
16276
|
+
function registrableDomain(domain) {
|
|
16277
|
+
const parsed = psl.parse(domain);
|
|
16278
|
+
if ("domain" in parsed && parsed.domain) return parsed.domain;
|
|
16279
|
+
return domain;
|
|
15936
16280
|
}
|
|
15937
16281
|
|
|
15938
|
-
// src/server/runner/
|
|
15939
|
-
var
|
|
15940
|
-
|
|
16282
|
+
// src/server/runner/postmaster.ts
|
|
16283
|
+
var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
16284
|
+
var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
|
|
16285
|
+
var FETCH_TIMEOUT_MS = 15e3;
|
|
16286
|
+
async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
16287
|
+
const ctl = new AbortController();
|
|
16288
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
16289
|
+
try {
|
|
16290
|
+
return await fetcher(url, { ...init, signal: ctl.signal });
|
|
16291
|
+
} finally {
|
|
16292
|
+
clearTimeout(t);
|
|
16293
|
+
}
|
|
16294
|
+
}
|
|
16295
|
+
function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
|
|
16296
|
+
let cached = null;
|
|
16297
|
+
async function getAccessToken() {
|
|
16298
|
+
if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
|
|
16299
|
+
return cached.accessToken;
|
|
16300
|
+
}
|
|
16301
|
+
const body = new URLSearchParams({
|
|
16302
|
+
client_id: cfg.clientId,
|
|
16303
|
+
client_secret: cfg.clientSecret,
|
|
16304
|
+
refresh_token: cfg.refreshToken,
|
|
16305
|
+
grant_type: "refresh_token"
|
|
16306
|
+
});
|
|
16307
|
+
const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
|
|
16308
|
+
method: "POST",
|
|
16309
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
16310
|
+
body: body.toString()
|
|
16311
|
+
});
|
|
16312
|
+
if (!res.ok) {
|
|
16313
|
+
throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
|
|
16314
|
+
}
|
|
16315
|
+
const data = await res.json();
|
|
16316
|
+
cached = {
|
|
16317
|
+
accessToken: data.access_token,
|
|
16318
|
+
expiresAt: Date.now() + data.expires_in * 1e3
|
|
16319
|
+
};
|
|
16320
|
+
return cached.accessToken;
|
|
16321
|
+
}
|
|
16322
|
+
async function authedGet(path) {
|
|
16323
|
+
const token = await getAccessToken();
|
|
16324
|
+
const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path}`, {
|
|
16325
|
+
method: "GET",
|
|
16326
|
+
headers: { authorization: `Bearer ${token}` }
|
|
16327
|
+
});
|
|
16328
|
+
if (!res.ok) {
|
|
16329
|
+
throw new Error(`Postmaster GET ${path} failed: ${res.status} ${await safeText(res)}`);
|
|
16330
|
+
}
|
|
16331
|
+
return await res.json();
|
|
16332
|
+
}
|
|
16333
|
+
return {
|
|
16334
|
+
async listDomains() {
|
|
16335
|
+
const data = await authedGet("/domains");
|
|
16336
|
+
return data.domains ?? [];
|
|
16337
|
+
},
|
|
16338
|
+
async getLatestTrafficStats(domain) {
|
|
16339
|
+
const data = await authedGet(
|
|
16340
|
+
`/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
|
|
16341
|
+
);
|
|
16342
|
+
return data.trafficStats?.[0] ?? null;
|
|
16343
|
+
}
|
|
16344
|
+
};
|
|
16345
|
+
}
|
|
16346
|
+
async function safeText(res) {
|
|
16347
|
+
try {
|
|
16348
|
+
return await res.text();
|
|
16349
|
+
} catch {
|
|
16350
|
+
return "<no body>";
|
|
16351
|
+
}
|
|
16352
|
+
}
|
|
16353
|
+
async function runPostmasterPull(ctx, opts = {}) {
|
|
16354
|
+
const cfg = ctx.config.postmaster;
|
|
16355
|
+
if (!cfg) return { ran: false, reason: "not_configured" };
|
|
16356
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
16357
|
+
if (!opts.force && intervalHours <= 0) {
|
|
16358
|
+
return { ran: false, reason: "disabled" };
|
|
16359
|
+
}
|
|
16360
|
+
const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
|
|
16361
|
+
const allDomains = resolveDomains(ctx, cfg);
|
|
16362
|
+
if (allDomains.length === 0) {
|
|
16363
|
+
return { ran: false, reason: "no_domains" };
|
|
16364
|
+
}
|
|
16365
|
+
let domains = allDomains;
|
|
16366
|
+
if (!opts.force) {
|
|
16367
|
+
const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
|
|
16368
|
+
const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
|
|
16369
|
+
const lastByDomain = /* @__PURE__ */ new Map();
|
|
16370
|
+
for (const s of latest) {
|
|
16371
|
+
const ts = new Date(s.fetchedAt).getTime();
|
|
16372
|
+
const cur = lastByDomain.get(s.domain) ?? 0;
|
|
16373
|
+
if (ts > cur) lastByDomain.set(s.domain, ts);
|
|
16374
|
+
}
|
|
16375
|
+
domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
|
|
16376
|
+
if (domains.length === 0) {
|
|
16377
|
+
return { ran: false, reason: "not_due" };
|
|
16378
|
+
}
|
|
16379
|
+
}
|
|
16380
|
+
const fetches = await Promise.all(
|
|
16381
|
+
domains.map(async (domain) => {
|
|
16382
|
+
try {
|
|
16383
|
+
const stat = await client.getLatestTrafficStats(domain);
|
|
16384
|
+
return { domain, stat, error: null };
|
|
16385
|
+
} catch (err) {
|
|
16386
|
+
console.error(`mailery: postmaster fetch failed for ${domain}`, err);
|
|
16387
|
+
return { domain, stat: null, error: err };
|
|
16388
|
+
}
|
|
16389
|
+
})
|
|
16390
|
+
);
|
|
16391
|
+
let fetched = 0;
|
|
16392
|
+
const trippedDomains = [];
|
|
16393
|
+
for (const { domain, stat } of fetches) {
|
|
16394
|
+
if (!stat) continue;
|
|
16395
|
+
const snapshot = toSnapshot(domain, stat);
|
|
16396
|
+
if (!snapshot) continue;
|
|
16397
|
+
fetched++;
|
|
16398
|
+
await ctx.collections.postmasterSnapshots.updateOne(
|
|
16399
|
+
{ domain: snapshot.domain, date: snapshot.date },
|
|
16400
|
+
{ $set: snapshot },
|
|
16401
|
+
{ upsert: true }
|
|
16402
|
+
);
|
|
16403
|
+
if (snapshot.domainReputation === "BAD") {
|
|
16404
|
+
const kindsToTrip = kindsForDomain(ctx, domain);
|
|
16405
|
+
for (const kind of kindsToTrip) {
|
|
16406
|
+
const tripped = await tripBucket(ctx, domain, kind, snapshot);
|
|
16407
|
+
if (tripped) trippedDomains.push(`${domain}|${kind}`);
|
|
16408
|
+
}
|
|
16409
|
+
}
|
|
16410
|
+
}
|
|
16411
|
+
return { ran: true, fetched, trippedDomains };
|
|
16412
|
+
}
|
|
16413
|
+
function kindsForDomain(ctx, domain) {
|
|
16414
|
+
const registry = ctx.config.senderDomains ?? {};
|
|
16415
|
+
const entry = registry[domain.toLowerCase()];
|
|
16416
|
+
if (!entry) return [];
|
|
16417
|
+
if (entry.kind === "both") return ["marketing", "transactional"];
|
|
16418
|
+
return [entry.kind];
|
|
16419
|
+
}
|
|
16420
|
+
function resolveDomains(ctx, cfg) {
|
|
16421
|
+
if (cfg.domains && cfg.domains.length > 0) {
|
|
16422
|
+
return cfg.domains.map((d) => d.toLowerCase());
|
|
16423
|
+
}
|
|
16424
|
+
const out = /* @__PURE__ */ new Set();
|
|
16425
|
+
const registry = ctx.config.senderDomains;
|
|
16426
|
+
if (registry) {
|
|
16427
|
+
for (const d of Object.keys(registry)) out.add(d.toLowerCase());
|
|
16428
|
+
}
|
|
16429
|
+
const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
|
|
16430
|
+
if (f) out.add(f);
|
|
16431
|
+
const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
|
|
16432
|
+
if (t) out.add(t);
|
|
16433
|
+
return Array.from(out);
|
|
16434
|
+
}
|
|
16435
|
+
function toSnapshot(domain, stat) {
|
|
16436
|
+
const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
|
|
16437
|
+
if (!m) {
|
|
16438
|
+
console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
|
|
16439
|
+
return null;
|
|
16440
|
+
}
|
|
16441
|
+
const yyyymmdd = m[1];
|
|
16442
|
+
const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
|
16443
|
+
return {
|
|
16444
|
+
domain,
|
|
16445
|
+
date,
|
|
16446
|
+
domainReputation: stat.domainReputation ?? null,
|
|
16447
|
+
ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
|
|
16448
|
+
reputation: r.reputation,
|
|
16449
|
+
ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
|
|
16450
|
+
})) : null,
|
|
16451
|
+
userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
|
|
16452
|
+
spfSuccessRatio: stat.spfSuccessRatio ?? null,
|
|
16453
|
+
dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
|
|
16454
|
+
dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
|
|
16455
|
+
outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
|
|
16456
|
+
inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
|
|
16457
|
+
deliveryErrors: stat.deliveryErrors ?? null,
|
|
16458
|
+
spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
|
|
16459
|
+
fetchedAt: /* @__PURE__ */ new Date()
|
|
16460
|
+
};
|
|
16461
|
+
}
|
|
16462
|
+
async function tripBucket(ctx, domain, kind, snapshot) {
|
|
16463
|
+
const id = healthBucketId(domain, kind);
|
|
16464
|
+
const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
|
|
16465
|
+
const prior = await ctx.collections.health.findOne({ _id: id });
|
|
16466
|
+
if (prior?.status === "tripped") {
|
|
16467
|
+
return false;
|
|
16468
|
+
}
|
|
15941
16469
|
await ctx.collections.health.updateOne(
|
|
15942
|
-
{ _id:
|
|
16470
|
+
{ _id: id },
|
|
15943
16471
|
{
|
|
15944
|
-
$set: {
|
|
16472
|
+
$set: {
|
|
16473
|
+
status: "tripped",
|
|
16474
|
+
trippedAt: /* @__PURE__ */ new Date(),
|
|
16475
|
+
trippedReason: reason,
|
|
16476
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
16477
|
+
},
|
|
15945
16478
|
$setOnInsert: {
|
|
15946
|
-
_id:
|
|
16479
|
+
_id: id,
|
|
16480
|
+
senderDomain: domain,
|
|
16481
|
+
kind,
|
|
15947
16482
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15948
16483
|
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
15949
|
-
status: "healthy",
|
|
15950
|
-
trippedAt: null,
|
|
15951
|
-
trippedReason: null,
|
|
15952
16484
|
manuallyResumedAt: null,
|
|
15953
16485
|
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
15954
16486
|
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
15955
16487
|
}
|
|
15956
16488
|
},
|
|
15957
16489
|
{ upsert: true }
|
|
15958
|
-
)
|
|
15959
|
-
|
|
16490
|
+
);
|
|
16491
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
16492
|
+
try {
|
|
16493
|
+
await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
|
|
16494
|
+
} catch {
|
|
16495
|
+
}
|
|
16496
|
+
}
|
|
16497
|
+
return true;
|
|
16498
|
+
}
|
|
16499
|
+
|
|
16500
|
+
// src/server/runner/snds.ts
|
|
16501
|
+
var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
|
|
16502
|
+
var FETCH_TIMEOUT_MS2 = 3e4;
|
|
16503
|
+
async function runSndsPull(ctx, opts = {}) {
|
|
16504
|
+
const cfg = ctx.config.snds;
|
|
16505
|
+
if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
|
|
16506
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
16507
|
+
if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
|
|
16508
|
+
if (!opts.force) {
|
|
16509
|
+
const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
|
|
16510
|
+
if (latest[0]) {
|
|
16511
|
+
const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
|
|
16512
|
+
if (ageMs < intervalHours * 60 * 60 * 1e3) {
|
|
16513
|
+
return { ran: false, reason: "not_due" };
|
|
16514
|
+
}
|
|
16515
|
+
}
|
|
16516
|
+
}
|
|
16517
|
+
const fetcher = opts.fetcher ?? globalThis.fetch;
|
|
16518
|
+
const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
|
|
16519
|
+
const ctl = new AbortController();
|
|
16520
|
+
const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
|
|
16521
|
+
let res;
|
|
16522
|
+
try {
|
|
16523
|
+
res = await fetcher(url, { method: "GET", signal: ctl.signal });
|
|
16524
|
+
} catch (err) {
|
|
16525
|
+
throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
|
|
16526
|
+
} finally {
|
|
16527
|
+
clearTimeout(t);
|
|
16528
|
+
}
|
|
16529
|
+
if (!res.ok) {
|
|
16530
|
+
throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
|
|
16531
|
+
}
|
|
16532
|
+
const csv = await res.text();
|
|
16533
|
+
const rows = parseSndsCsv(csv);
|
|
16534
|
+
const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
|
|
16535
|
+
const now = /* @__PURE__ */ new Date();
|
|
16536
|
+
const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
|
|
16537
|
+
updateOne: {
|
|
16538
|
+
filter: { ip: row.ip, activityStart: row.activityStart },
|
|
16539
|
+
update: { $set: { ...row, fetchedAt: now } },
|
|
16540
|
+
upsert: true
|
|
16541
|
+
}
|
|
16542
|
+
}));
|
|
16543
|
+
let persisted = 0;
|
|
16544
|
+
if (ops.length > 0) {
|
|
16545
|
+
const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
|
|
16546
|
+
persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
|
|
16547
|
+
}
|
|
16548
|
+
return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
|
|
16549
|
+
}
|
|
16550
|
+
function redactKey(s, key) {
|
|
16551
|
+
if (!key) return s;
|
|
16552
|
+
return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
|
|
16553
|
+
}
|
|
16554
|
+
async function safeText2(res) {
|
|
16555
|
+
try {
|
|
16556
|
+
return await res.text();
|
|
16557
|
+
} catch {
|
|
16558
|
+
return "<no body>";
|
|
16559
|
+
}
|
|
16560
|
+
}
|
|
16561
|
+
function parseSndsCsv(csv) {
|
|
16562
|
+
const out = [];
|
|
16563
|
+
for (const raw of csv.split(/\r?\n/)) {
|
|
16564
|
+
if (!raw.trim()) continue;
|
|
16565
|
+
const fields = splitCsvRow(raw);
|
|
16566
|
+
if (fields.length < 7) continue;
|
|
16567
|
+
const parsed = parseRow(fields);
|
|
16568
|
+
if (parsed) out.push(parsed);
|
|
16569
|
+
}
|
|
16570
|
+
return out;
|
|
16571
|
+
}
|
|
16572
|
+
function splitCsvRow(row) {
|
|
16573
|
+
const out = [];
|
|
16574
|
+
let cur = "";
|
|
16575
|
+
let inQuotes = false;
|
|
16576
|
+
for (let i = 0; i < row.length; i++) {
|
|
16577
|
+
const ch = row[i];
|
|
16578
|
+
if (inQuotes) {
|
|
16579
|
+
if (ch === '"') {
|
|
16580
|
+
if (row[i + 1] === '"') {
|
|
16581
|
+
cur += '"';
|
|
16582
|
+
i++;
|
|
16583
|
+
} else {
|
|
16584
|
+
inQuotes = false;
|
|
16585
|
+
}
|
|
16586
|
+
} else {
|
|
16587
|
+
cur += ch;
|
|
16588
|
+
}
|
|
16589
|
+
} else if (ch === '"') {
|
|
16590
|
+
inQuotes = true;
|
|
16591
|
+
} else if (ch === ",") {
|
|
16592
|
+
out.push(cur);
|
|
16593
|
+
cur = "";
|
|
16594
|
+
} else {
|
|
16595
|
+
cur += ch;
|
|
16596
|
+
}
|
|
16597
|
+
}
|
|
16598
|
+
out.push(cur);
|
|
16599
|
+
return out.map((f) => f.trim());
|
|
16600
|
+
}
|
|
16601
|
+
function parseRow(fields) {
|
|
16602
|
+
const [
|
|
16603
|
+
ip,
|
|
16604
|
+
activityStart,
|
|
16605
|
+
activityEnd,
|
|
16606
|
+
rcptCommands,
|
|
16607
|
+
dataCommands,
|
|
16608
|
+
messageRecipients,
|
|
16609
|
+
filterResult,
|
|
16610
|
+
complaintRate,
|
|
16611
|
+
trapMessageCount,
|
|
16612
|
+
sampleHelo,
|
|
16613
|
+
sampleMailFrom
|
|
16614
|
+
] = fields;
|
|
16615
|
+
const start = parseSndsDate(activityStart ?? "");
|
|
16616
|
+
const end = parseSndsDate(activityEnd ?? "");
|
|
16617
|
+
if (!ip || !start || !end) return null;
|
|
16618
|
+
return {
|
|
16619
|
+
ip,
|
|
16620
|
+
activityStart: start,
|
|
16621
|
+
activityEnd: end,
|
|
16622
|
+
rcptCommands: toInt(rcptCommands),
|
|
16623
|
+
dataCommands: toInt(dataCommands),
|
|
16624
|
+
messageRecipients: toInt(messageRecipients),
|
|
16625
|
+
filterResult: normalizeFilter(filterResult ?? ""),
|
|
16626
|
+
complaintRate: parseComplaintRate(complaintRate ?? ""),
|
|
16627
|
+
trapMessageCount: toInt(trapMessageCount),
|
|
16628
|
+
sampleHelo: sampleHelo ? sampleHelo : null,
|
|
16629
|
+
sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
|
|
16630
|
+
};
|
|
16631
|
+
}
|
|
16632
|
+
function toInt(s) {
|
|
16633
|
+
if (!s) return 0;
|
|
16634
|
+
const n = Number(s.replace(/[^\d-]/g, ""));
|
|
16635
|
+
return Number.isFinite(n) ? n : 0;
|
|
16636
|
+
}
|
|
16637
|
+
function normalizeFilter(s) {
|
|
16638
|
+
const u = s.toUpperCase().trim();
|
|
16639
|
+
if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
|
|
16640
|
+
return "UNKNOWN";
|
|
16641
|
+
}
|
|
16642
|
+
function parseComplaintRate(s) {
|
|
16643
|
+
const trimmed = s.trim();
|
|
16644
|
+
if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
|
|
16645
|
+
const cleaned = trimmed.replace(/%/g, "");
|
|
16646
|
+
let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16647
|
+
if (m) return Number(m[1]) / 100;
|
|
16648
|
+
m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16649
|
+
if (m) return Number(m[1]) / 100;
|
|
16650
|
+
m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16651
|
+
if (m) return Number(m[2]) / 100;
|
|
16652
|
+
m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16653
|
+
if (m) return Number(m[1]) / 100;
|
|
16654
|
+
return null;
|
|
16655
|
+
}
|
|
16656
|
+
function parseSndsDate(s) {
|
|
16657
|
+
const trimmed = s.trim();
|
|
16658
|
+
if (!trimmed) return null;
|
|
16659
|
+
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
|
|
16660
|
+
if (m) {
|
|
16661
|
+
const month = Number(m[1]) - 1;
|
|
16662
|
+
const day = Number(m[2]);
|
|
16663
|
+
const year = Number(m[3]);
|
|
16664
|
+
let hour = Number(m[4]);
|
|
16665
|
+
const minute = Number(m[5]);
|
|
16666
|
+
const second = Number(m[6] ?? 0);
|
|
16667
|
+
const meridiem = m[7]?.toUpperCase();
|
|
16668
|
+
if (meridiem === "PM" && hour < 12) hour += 12;
|
|
16669
|
+
if (meridiem === "AM" && hour === 12) hour = 0;
|
|
16670
|
+
const wallMs = Date.UTC(year, month, day, hour, minute, second);
|
|
16671
|
+
if (!Number.isFinite(wallMs)) return null;
|
|
16672
|
+
const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
|
|
16673
|
+
return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
|
|
16674
|
+
}
|
|
16675
|
+
const d = new Date(trimmed);
|
|
16676
|
+
if (!isNaN(d.getTime())) return d;
|
|
16677
|
+
return null;
|
|
16678
|
+
}
|
|
16679
|
+
function isUsPacificDst(year, month0, day) {
|
|
16680
|
+
if (month0 > 2 && month0 < 10) return true;
|
|
16681
|
+
if (month0 < 2 || month0 > 10) return false;
|
|
16682
|
+
if (month0 === 2) {
|
|
16683
|
+
const dst2 = nthSundayOfMonth(year, 2, 2);
|
|
16684
|
+
return day >= dst2;
|
|
16685
|
+
}
|
|
16686
|
+
const dst = nthSundayOfMonth(year, 10, 1);
|
|
16687
|
+
return day < dst;
|
|
16688
|
+
}
|
|
16689
|
+
function nthSundayOfMonth(year, month0, n) {
|
|
16690
|
+
const first = new Date(Date.UTC(year, month0, 1));
|
|
16691
|
+
const dayOfWeek = first.getUTCDay();
|
|
16692
|
+
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
16693
|
+
return firstSunday + (n - 1) * 7;
|
|
16694
|
+
}
|
|
16695
|
+
var _lastPruneAt = 0;
|
|
16696
|
+
var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
|
|
16697
|
+
async function pruneDmarcFailures(ctx, opts = {}) {
|
|
16698
|
+
const cfg = ctx.config.dmarc;
|
|
16699
|
+
const days = cfg?.retentionDays ?? 90;
|
|
16700
|
+
if (days <= 0) return 0;
|
|
16701
|
+
if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
|
|
16702
|
+
_lastPruneAt = Date.now();
|
|
16703
|
+
const cutoff = new Date(Date.now() - days * 864e5);
|
|
16704
|
+
const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
|
|
16705
|
+
return r.deletedCount ?? 0;
|
|
16706
|
+
}
|
|
16707
|
+
|
|
16708
|
+
// src/server/runner/tick.ts
|
|
16709
|
+
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16710
|
+
async function runTick(ctx) {
|
|
16711
|
+
try {
|
|
16712
|
+
await ctx.collections.health.updateOne(
|
|
16713
|
+
{ _id: HEALTH_AGG_ID },
|
|
16714
|
+
{
|
|
16715
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() },
|
|
16716
|
+
$setOnInsert: {
|
|
16717
|
+
_id: HEALTH_AGG_ID,
|
|
16718
|
+
senderDomain: null,
|
|
16719
|
+
kind: null,
|
|
16720
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
16721
|
+
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
16722
|
+
status: "healthy",
|
|
16723
|
+
trippedAt: null,
|
|
16724
|
+
trippedReason: null,
|
|
16725
|
+
manuallyResumedAt: null,
|
|
16726
|
+
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
16727
|
+
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
16728
|
+
}
|
|
16729
|
+
},
|
|
16730
|
+
{ upsert: true }
|
|
16731
|
+
);
|
|
16732
|
+
} catch (err) {
|
|
16733
|
+
console.error("mailery: heartbeat write failed", err);
|
|
16734
|
+
}
|
|
15960
16735
|
await processNewlyFiredEventTriggers(ctx).catch((err) => {
|
|
15961
16736
|
console.error("mailery: triggers scan failed", err);
|
|
15962
16737
|
});
|
|
@@ -15978,6 +16753,20 @@ async function runTick(ctx) {
|
|
|
15978
16753
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
15979
16754
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
15980
16755
|
});
|
|
16756
|
+
await Promise.all([
|
|
16757
|
+
runDnsblChecks(ctx).catch((err) => {
|
|
16758
|
+
console.error("mailery: dnsbl checks failed", err);
|
|
16759
|
+
}),
|
|
16760
|
+
runPostmasterPull(ctx).catch((err) => {
|
|
16761
|
+
console.error("mailery: postmaster pull failed", err);
|
|
16762
|
+
}),
|
|
16763
|
+
runSndsPull(ctx).catch((err) => {
|
|
16764
|
+
console.error("mailery: snds pull failed", err);
|
|
16765
|
+
}),
|
|
16766
|
+
pruneDmarcFailures(ctx).catch((err) => {
|
|
16767
|
+
console.error("mailery: dmarc prune failed", err);
|
|
16768
|
+
})
|
|
16769
|
+
]);
|
|
15981
16770
|
}
|
|
15982
16771
|
async function sweepStrandedSends(ctx) {
|
|
15983
16772
|
const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
|
|
@@ -16035,6 +16824,10 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
16035
16824
|
}
|
|
16036
16825
|
|
|
16037
16826
|
// src/server/runner/webhook.ts
|
|
16827
|
+
function dimsFromSend(send) {
|
|
16828
|
+
if (!send) return null;
|
|
16829
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
16830
|
+
}
|
|
16038
16831
|
async function applyWebhookEvent(event, ctx) {
|
|
16039
16832
|
const send = await ctx.collections.sends.findOne(
|
|
16040
16833
|
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
@@ -16048,7 +16841,7 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
16048
16841
|
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
16049
16842
|
);
|
|
16050
16843
|
}
|
|
16051
|
-
await recordHealthCounter(ctx, "delivered");
|
|
16844
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
16052
16845
|
break;
|
|
16053
16846
|
case "open":
|
|
16054
16847
|
if (send) {
|
|
@@ -16103,8 +16896,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
16103
16896
|
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16104
16897
|
);
|
|
16105
16898
|
}
|
|
16106
|
-
|
|
16107
|
-
|
|
16899
|
+
{
|
|
16900
|
+
const dims = dimsFromSend(send);
|
|
16901
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
16902
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
16903
|
+
}
|
|
16108
16904
|
break;
|
|
16109
16905
|
}
|
|
16110
16906
|
case "complaint":
|
|
@@ -16120,7 +16916,7 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
16120
16916
|
{ emailAtSubscribe: event.email },
|
|
16121
16917
|
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16122
16918
|
);
|
|
16123
|
-
await recordHealthCounter(ctx, "complained");
|
|
16919
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
16124
16920
|
break;
|
|
16125
16921
|
case "unsubscribe":
|
|
16126
16922
|
if (send) {
|
|
@@ -16189,7 +16985,8 @@ var Mailer = class _Mailer {
|
|
|
16189
16985
|
providers: this.providers,
|
|
16190
16986
|
queues: this.queues,
|
|
16191
16987
|
config: this.config,
|
|
16192
|
-
handlebarsHelpers: this.config.handlebarsHelpers
|
|
16988
|
+
handlebarsHelpers: this.config.handlebarsHelpers,
|
|
16989
|
+
audit: (entry) => this.audit(entry)
|
|
16193
16990
|
};
|
|
16194
16991
|
}
|
|
16195
16992
|
/**
|