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/testing.cjs CHANGED
@@ -8,6 +8,9 @@ var IORedis = require('ioredis');
8
8
  var Handlebars = require('handlebars');
9
9
  var htmlToText = require('html-to-text');
10
10
  var mjml2html = require('mjml');
11
+ var dns = require('dns/promises');
12
+ var net = require('net');
13
+ var psl = require('psl');
11
14
 
12
15
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
16
 
@@ -16,6 +19,9 @@ var sgMail__default = /*#__PURE__*/_interopDefault(sgMail);
16
19
  var IORedis__default = /*#__PURE__*/_interopDefault(IORedis);
17
20
  var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
18
21
  var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
22
+ var dns__default = /*#__PURE__*/_interopDefault(dns);
23
+ var net__default = /*#__PURE__*/_interopDefault(net);
24
+ var psl__default = /*#__PURE__*/_interopDefault(psl);
19
25
 
20
26
  var __create = Object.create;
21
27
  var __defProp = Object.defineProperty;
@@ -4165,7 +4171,7 @@ var require_getport = __commonJS({
4165
4171
  exports.tryPort = tryPort;
4166
4172
  exports.resetPortsCache = resetPortsCache;
4167
4173
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
4168
- var net = tslib_1.__importStar(__require("net"));
4174
+ var net2 = tslib_1.__importStar(__require("net"));
4169
4175
  var debug_1 = tslib_1.__importDefault(require_src());
4170
4176
  var log = (0, debug_1.default)("MongoMS:GetPort");
4171
4177
  exports.MIN_PORT = 1024;
@@ -4213,7 +4219,7 @@ var require_getport = __commonJS({
4213
4219
  }
4214
4220
  function tryPort(port) {
4215
4221
  return new Promise((res, rej) => {
4216
- const server = net.createServer();
4222
+ const server = net2.createServer();
4217
4223
  if (typeof server.unref === "function") {
4218
4224
  server.unref();
4219
4225
  }
@@ -9179,7 +9185,7 @@ var require_dist = __commonJS({
9179
9185
  };
9180
9186
  Object.defineProperty(exports, "__esModule", { value: true });
9181
9187
  exports.Agent = void 0;
9182
- var net = __importStar2(__require("net"));
9188
+ var net2 = __importStar2(__require("net"));
9183
9189
  var http = __importStar2(__require("http"));
9184
9190
  var https_1 = __require("https");
9185
9191
  __exportStar2(require_helpers(), exports);
@@ -9219,7 +9225,7 @@ var require_dist = __commonJS({
9219
9225
  if (!this.sockets[name]) {
9220
9226
  this.sockets[name] = [];
9221
9227
  }
9222
- const fakeSocket = new net.Socket({ writable: false });
9228
+ const fakeSocket = new net2.Socket({ writable: false });
9223
9229
  this.sockets[name].push(fakeSocket);
9224
9230
  this.totalSocketCount++;
9225
9231
  return fakeSocket;
@@ -9429,7 +9435,7 @@ var require_dist2 = __commonJS({
9429
9435
  };
9430
9436
  Object.defineProperty(exports, "__esModule", { value: true });
9431
9437
  exports.HttpsProxyAgent = void 0;
9432
- var net = __importStar2(__require("net"));
9438
+ var net2 = __importStar2(__require("net"));
9433
9439
  var tls = __importStar2(__require("tls"));
9434
9440
  var assert_1 = __importDefault2(__require("assert"));
9435
9441
  var debug_1 = __importDefault2(require_src());
@@ -9438,7 +9444,7 @@ var require_dist2 = __commonJS({
9438
9444
  var parse_proxy_response_1 = require_parse_proxy_response();
9439
9445
  var debug = (0, debug_1.default)("https-proxy-agent");
9440
9446
  var setServernameFromNonIpHost = (options2) => {
9441
- if (options2.servername === void 0 && options2.host && !net.isIP(options2.host)) {
9447
+ if (options2.servername === void 0 && options2.host && !net2.isIP(options2.host)) {
9442
9448
  return {
9443
9449
  ...options2,
9444
9450
  servername: options2.host
@@ -9478,10 +9484,10 @@ var require_dist2 = __commonJS({
9478
9484
  socket = tls.connect(setServernameFromNonIpHost(this.connectOpts));
9479
9485
  } else {
9480
9486
  debug("Creating `net.Socket`: %o", this.connectOpts);
9481
- socket = net.connect(this.connectOpts);
9487
+ socket = net2.connect(this.connectOpts);
9482
9488
  }
9483
9489
  const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders };
9484
- const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
9490
+ const host = net2.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
9485
9491
  let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r
9486
9492
  `;
9487
9493
  if (proxy.username || proxy.password) {
@@ -9514,7 +9520,7 @@ var require_dist2 = __commonJS({
9514
9520
  return socket;
9515
9521
  }
9516
9522
  socket.destroy();
9517
- const fakeSocket = new net.Socket({ writable: false });
9523
+ const fakeSocket = new net2.Socket({ writable: false });
9518
9524
  fakeSocket.readable = true;
9519
9525
  req.once("socket", (s) => {
9520
9526
  debug("Replaying proxy buffer for failed request");
@@ -14263,6 +14269,17 @@ var predicateSchema = zod.z.lazy(
14263
14269
  );
14264
14270
 
14265
14271
  // src/server/config.ts
14272
+ var DEFAULT_DOMAIN_DNSBL_LISTS = [
14273
+ { host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
14274
+ { host: "multi.surbl.org", label: "SURBL" },
14275
+ { host: "multi.uribl.com", label: "URIBL" }
14276
+ ];
14277
+ var DEFAULT_IP_DNSBL_LISTS = [
14278
+ { host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
14279
+ { host: "b.barracudacentral.org", label: "Barracuda" },
14280
+ { host: "dnsbl.sorbs.net", label: "SORBS" },
14281
+ { host: "bl.spamcop.net", label: "SpamCop" }
14282
+ ];
14266
14283
  var DEFAULTS = {
14267
14284
  collectionPrefix: "mailer_",
14268
14285
  requireDoubleOptIn: false,
@@ -14323,6 +14340,10 @@ function resolveConfig(c) {
14323
14340
  }
14324
14341
 
14325
14342
  // src/server/models/index.ts
14343
+ var HEALTH_AGG_ID = "agg";
14344
+ function healthBucketId(senderDomain, kind) {
14345
+ return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
14346
+ }
14326
14347
  function getCollections(db, prefix = "mailer_") {
14327
14348
  return {
14328
14349
  subscriptions: db.collection(`${prefix}subscriptions`),
@@ -14340,7 +14361,14 @@ function getCollections(db, prefix = "mailer_") {
14340
14361
  auditLog: db.collection(`${prefix}audit_log`),
14341
14362
  webhookEvents: db.collection(`${prefix}webhook_events`),
14342
14363
  health: db.collection(`${prefix}health`),
14343
- contactTags: db.collection(`${prefix}contact_tags`)
14364
+ contactTags: db.collection(`${prefix}contact_tags`),
14365
+ dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
14366
+ postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
14367
+ sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
14368
+ dmarcReports: db.collection(`${prefix}dmarc_reports`),
14369
+ dmarcFailures: db.collection(`${prefix}dmarc_failures`),
14370
+ dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
14371
+ mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
14344
14372
  };
14345
14373
  }
14346
14374
  async function ensureIndexes(db, prefix = "mailer_") {
@@ -14412,8 +14440,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
14412
14440
  c.contactTags.createIndexes([
14413
14441
  { key: { externalId: 1, tag: 1 }, unique: true },
14414
14442
  { key: { tag: 1 } }
14443
+ ]),
14444
+ c.health.createIndexes([
14445
+ { key: { senderDomain: 1, kind: 1 } },
14446
+ { key: { status: 1 } },
14447
+ // setup-status reads the most-recently-touched health doc as a heartbeat.
14448
+ { key: { updatedAt: -1 } }
14449
+ ]),
14450
+ c.dnsblChecks.createIndexes([
14451
+ { key: { target: 1, list: 1 }, unique: true },
14452
+ // Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
14453
+ { key: { result: 1, target: 1, list: 1 } },
14454
+ // TTL — stale rows for targets the operator removed disappear after
14455
+ // 60 days without manual cleanup. The puller refreshes runAt on
14456
+ // every active target so existing targets stay indefinitely.
14457
+ { key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
14458
+ ]),
14459
+ c.postmasterSnapshots.createIndexes([
14460
+ { key: { domain: 1, date: 1 }, unique: true },
14461
+ { key: { domain: 1, fetchedAt: -1 } },
14462
+ { key: { domainReputation: 1 } }
14463
+ ]),
14464
+ c.sndsSnapshots.createIndexes([
14465
+ { key: { ip: 1, activityStart: 1 }, unique: true },
14466
+ { key: { ip: 1, fetchedAt: -1 } },
14467
+ { key: { filterResult: 1 } }
14468
+ ]),
14469
+ c.dmarcReports.createIndexes([
14470
+ { key: { reportId: 1, orgName: 1 }, unique: true },
14471
+ { key: { domain: 1, rangeEnd: -1 } },
14472
+ // Cross-domain "most recent reports" queries scan a lot without this.
14473
+ { key: { rangeEnd: -1 } },
14474
+ { key: { receivedAt: -1 } }
14475
+ ]),
14476
+ c.dmarcFailures.createIndexes([
14477
+ { key: { reportId: 1, sourceIp: 1 }, unique: true },
14478
+ { key: { domain: 1, day: -1 } },
14479
+ { key: { sourceIp: 1, day: -1 } },
14480
+ { key: { receivedAt: 1 } }
14481
+ // for retention pruning
14482
+ ]),
14483
+ c.dmarcSourceTags.createIndexes([
14484
+ { key: { ip: 1 }, unique: true }
14485
+ ]),
14486
+ c.mailTesterScores.createIndexes([
14487
+ { key: { contentKey: 1 }, unique: true },
14488
+ { key: { templateSlug: 1, fetchedAt: -1 } },
14489
+ // TTL — Mongo auto-deletes expired scores so we never serve stale data.
14490
+ { key: { expiresAt: 1 }, expireAfterSeconds: 0 }
14415
14491
  ])
14416
14492
  ]);
14493
+ await c.health.deleteOne({ _id: "singleton" }).catch(() => {
14494
+ });
14417
14495
  }
14418
14496
  var EventRegistry = class {
14419
14497
  policies = /* @__PURE__ */ new Map();
@@ -14426,6 +14504,9 @@ var EventRegistry = class {
14426
14504
  policy(name) {
14427
14505
  return this.policies.get(name);
14428
14506
  }
14507
+ list() {
14508
+ return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
14509
+ }
14429
14510
  /**
14430
14511
  * Derive a dedupeKey for an event call. Returns null when no policy is
14431
14512
  * registered AND no key was passed — caller should throw.
@@ -15075,21 +15156,59 @@ async function isSuppressed(collections, email, kind) {
15075
15156
  return { suppressed: false };
15076
15157
  }
15077
15158
 
15159
+ // src/server/templates/sender-domain.ts
15160
+ function extractDomain(email) {
15161
+ if (typeof email !== "string") return null;
15162
+ const at = email.lastIndexOf("@");
15163
+ if (at <= 0 || at === email.length - 1) return null;
15164
+ return email.slice(at + 1).toLowerCase().trim();
15165
+ }
15166
+
15078
15167
  // src/server/runner/health.ts
15079
- async function recordHealthCounter(ctx, counter2, by = 1) {
15168
+ var ZERO_COUNTERS = {
15169
+ sent: 0,
15170
+ delivered: 0,
15171
+ bounced: 0,
15172
+ hardBounced: 0,
15173
+ softBounced: 0,
15174
+ complained: 0,
15175
+ failedToSend: 0
15176
+ };
15177
+ var ZERO_RATES = {
15178
+ bounceRate: 0,
15179
+ hardBounceRate: 0,
15180
+ complaintRate: 0,
15181
+ failureRate: 0
15182
+ };
15183
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15184
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
15185
+ const writes = [];
15186
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
15187
+ if (dims) {
15188
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
15189
+ if (domain) {
15190
+ const id = healthBucketId(domain, dims.kind);
15191
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
15192
+ }
15193
+ }
15194
+ await Promise.all(writes);
15195
+ }
15196
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
15080
15197
  await ctx.collections.health.updateOne(
15081
- { _id: "singleton" },
15198
+ { _id },
15082
15199
  {
15083
15200
  $inc: { [`counters.${counter2}`]: by },
15084
15201
  $setOnInsert: {
15085
- _id: "singleton",
15202
+ _id,
15203
+ senderDomain,
15204
+ kind,
15086
15205
  windowStartedAt: /* @__PURE__ */ new Date(),
15087
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15206
+ windowDurationMs: windowMs,
15088
15207
  status: "healthy",
15089
15208
  trippedAt: null,
15090
15209
  trippedReason: null,
15091
15210
  manuallyResumedAt: null,
15092
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15211
+ rates: { ...ZERO_RATES }
15093
15212
  },
15094
15213
  $set: { updatedAt: /* @__PURE__ */ new Date() }
15095
15214
  },
@@ -15099,73 +15218,107 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
15099
15218
  async function evaluateHealth(ctx) {
15100
15219
  const cb = ctx.config.circuitBreaker;
15101
15220
  const windowMs = cb.windowMinutes * 60 * 1e3;
15102
- const doc = await ctx.collections.health.findOne({ _id: "singleton" });
15103
- if (!doc) return;
15104
- const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15105
- if (windowAge > windowMs && doc.status !== "tripped") {
15106
- await ctx.collections.health.updateOne(
15107
- { _id: "singleton" },
15108
- {
15109
- $set: {
15110
- windowStartedAt: /* @__PURE__ */ new Date(),
15111
- windowDurationMs: windowMs,
15112
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15113
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 },
15114
- updatedAt: /* @__PURE__ */ new Date()
15115
- }
15116
- }
15117
- );
15118
- return;
15221
+ const docs = await ctx.collections.health.find({}).toArray();
15222
+ if (docs.length === 0) return;
15223
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
15224
+ if (!hasAgg) {
15225
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
15119
15226
  }
15120
- const c = doc.counters;
15121
- const total = c.sent || 1;
15122
- const rates = {
15123
- bounceRate: c.bounced / total,
15124
- hardBounceRate: c.hardBounced / total,
15125
- complaintRate: c.complained / total,
15126
- failureRate: c.failedToSend / total
15127
- };
15128
- await ctx.collections.health.updateOne(
15129
- { _id: "singleton" },
15130
- { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15131
- );
15132
- if (c.sent < cb.minSendsBeforeEval) return;
15133
- if (doc.status === "tripped") return;
15134
- let trippedReason = null;
15135
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15136
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15137
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15138
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15139
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15140
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15141
- }
15142
- if (trippedReason) {
15227
+ for (const doc of docs) {
15228
+ const isAgg = doc._id === HEALTH_AGG_ID;
15229
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15230
+ if (windowAge > windowMs && doc.status !== "tripped") {
15231
+ await ctx.collections.health.updateOne(
15232
+ { _id: doc._id },
15233
+ {
15234
+ $set: {
15235
+ windowStartedAt: /* @__PURE__ */ new Date(),
15236
+ windowDurationMs: windowMs,
15237
+ counters: { ...ZERO_COUNTERS },
15238
+ rates: { ...ZERO_RATES },
15239
+ status: "healthy",
15240
+ updatedAt: /* @__PURE__ */ new Date()
15241
+ }
15242
+ }
15243
+ );
15244
+ continue;
15245
+ }
15246
+ const c = doc.counters;
15247
+ const total = c.sent || 1;
15248
+ const rates = {
15249
+ bounceRate: c.bounced / total,
15250
+ hardBounceRate: c.hardBounced / total,
15251
+ complaintRate: c.complained / total,
15252
+ failureRate: c.failedToSend / total
15253
+ };
15143
15254
  await ctx.collections.health.updateOne(
15144
- { _id: "singleton" },
15145
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15255
+ { _id: doc._id },
15256
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15146
15257
  );
15147
- if (ctx.config.onCircuitBreakerTrip) {
15148
- try {
15149
- await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
15150
- } catch {
15258
+ if (isAgg) continue;
15259
+ if (c.sent < cb.minSendsBeforeEval) continue;
15260
+ if (doc.status === "tripped") continue;
15261
+ let trippedReason = null;
15262
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15263
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15264
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15265
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15266
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15267
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15268
+ }
15269
+ if (trippedReason) {
15270
+ const result = await ctx.collections.health.updateOne(
15271
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
15272
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15273
+ );
15274
+ if (result.modifiedCount > 0) {
15275
+ if (ctx.audit) {
15276
+ try {
15277
+ await ctx.audit({
15278
+ actor: "system:circuit-breaker",
15279
+ action: "health.trip",
15280
+ resource: {
15281
+ collection: "mailer_health",
15282
+ id: String(doc._id),
15283
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
15284
+ },
15285
+ diffSummary: trippedReason
15286
+ });
15287
+ } catch {
15288
+ }
15289
+ }
15290
+ if (ctx.config.onCircuitBreakerTrip) {
15291
+ try {
15292
+ await ctx.config.onCircuitBreakerTrip({
15293
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
15294
+ rates
15295
+ });
15296
+ } catch {
15297
+ }
15298
+ }
15151
15299
  }
15300
+ continue;
15152
15301
  }
15153
- return;
15154
- }
15155
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15156
- if (doc.status !== "degraded") {
15302
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15303
+ if (doc.status !== "degraded") {
15304
+ await ctx.collections.health.updateOne(
15305
+ { _id: doc._id },
15306
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15307
+ );
15308
+ }
15309
+ } else if (doc.status === "degraded") {
15157
15310
  await ctx.collections.health.updateOne(
15158
- { _id: "singleton" },
15159
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15311
+ { _id: doc._id },
15312
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15160
15313
  );
15161
15314
  }
15162
- } else if (doc.status === "degraded") {
15163
- await ctx.collections.health.updateOne(
15164
- { _id: "singleton" },
15165
- { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15166
- );
15167
15315
  }
15168
15316
  }
15317
+ async function getBucketStatus(ctx, fromEmail, kind) {
15318
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
15319
+ const id = healthBucketId(domain, kind);
15320
+ return ctx.collections.health.findOne({ _id: id });
15321
+ }
15169
15322
 
15170
15323
  // src/server/runner/send.ts
15171
15324
  async function handleSend(run, step, contact, flow, ctx) {
@@ -15249,8 +15402,8 @@ async function dispatchSend(sendId, ctx) {
15249
15402
  return;
15250
15403
  }
15251
15404
  if (send.kind === "marketing") {
15252
- const health = await ctx.collections.health.findOne({ _id: "singleton" });
15253
- if (health?.status === "tripped") {
15405
+ const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
15406
+ if (bucket?.status === "tripped") {
15254
15407
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
15255
15408
  return;
15256
15409
  }
@@ -15319,13 +15472,13 @@ async function dispatchSend(sendId, ctx) {
15319
15472
  }
15320
15473
  }
15321
15474
  );
15322
- await recordHealthCounter(ctx, "sent");
15475
+ await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
15323
15476
  } catch (err) {
15324
15477
  await ctx.collections.sends.updateOne(
15325
15478
  { _id: send._id },
15326
15479
  { $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
15327
15480
  );
15328
- await recordHealthCounter(ctx, "failedToSend");
15481
+ await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
15329
15482
  if (ctx.config.onSendFailure) {
15330
15483
  try {
15331
15484
  await ctx.config.onSendFailure({ send, error: err });
@@ -15909,7 +16062,15 @@ async function promoteSoftBounces(ctx) {
15909
16062
  const cutoff = new Date(Date.now() - windowDays * 864e5);
15910
16063
  const offenders = await ctx.collections.sends.aggregate([
15911
16064
  { $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
15912
- { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
16065
+ { $sort: { queuedAt: 1 } },
16066
+ {
16067
+ $group: {
16068
+ _id: "$emailAtSend",
16069
+ count: { $sum: 1 },
16070
+ lastFromEmail: { $last: "$fromEmail" },
16071
+ lastKind: { $last: "$kind" }
16072
+ }
16073
+ },
15913
16074
  { $match: { count: { $gte: threshold } } },
15914
16075
  { $limit: 200 }
15915
16076
  ]).toArray();
@@ -15941,32 +16102,649 @@ async function promoteSoftBounces(ctx) {
15941
16102
  { emailAtSubscribe: email },
15942
16103
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
15943
16104
  );
15944
- await recordHealthCounter(ctx, "hardBounced");
16105
+ await recordHealthCounter(
16106
+ ctx,
16107
+ "hardBounced",
16108
+ o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
16109
+ );
16110
+ }
16111
+ }
16112
+ var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
16113
+ var DNS_CONCURRENCY = 8;
16114
+ var defaultResolver = {
16115
+ resolve4: (hostname) => dns__default.default.resolve4(hostname)
16116
+ };
16117
+ async function runDnsblChecks(ctx, opts = {}) {
16118
+ const cfg = ctx.config.dnsbl ?? {};
16119
+ const intervalHours = cfg.intervalHours ?? 24;
16120
+ if (!opts.force && intervalHours <= 0) {
16121
+ return { ran: false, reason: "disabled" };
16122
+ }
16123
+ const targets = collectTargets(ctx, cfg);
16124
+ if (targets.domains.length === 0 && targets.ips.length === 0) {
16125
+ return { ran: false, reason: "no_targets" };
16126
+ }
16127
+ const resolver = opts.resolver ?? defaultResolver;
16128
+ const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
16129
+ const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
16130
+ const pairs = [];
16131
+ for (const d of targets.domains) {
16132
+ for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
16133
+ }
16134
+ for (const ip of targets.ips) {
16135
+ for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
16136
+ }
16137
+ const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
16138
+ let duePairs = pairs;
16139
+ if (throttleCutoff != null) {
16140
+ const existing = await ctx.collections.dnsblChecks.find(
16141
+ {
16142
+ $or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
16143
+ },
16144
+ { projection: { target: 1, list: 1, runAt: 1 } }
16145
+ ).toArray();
16146
+ const fresh = /* @__PURE__ */ new Set();
16147
+ for (const e of existing) {
16148
+ if (new Date(e.runAt).getTime() > throttleCutoff) {
16149
+ fresh.add(`${e.target}|${e.list}`);
16150
+ }
16151
+ }
16152
+ duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
16153
+ if (duePairs.length === 0) {
16154
+ return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
16155
+ }
16156
+ }
16157
+ let listedCount = 0;
16158
+ async function processPair(p) {
16159
+ const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
16160
+ const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
16161
+ if (lookup.transient) return;
16162
+ if (lookup.result === "listed") listedCount++;
16163
+ await ctx.collections.dnsblChecks.updateOne(
16164
+ { target: p.target, list: p.list.host },
16165
+ {
16166
+ $set: {
16167
+ target: p.target,
16168
+ targetKind: p.targetKind,
16169
+ list: p.list.host,
16170
+ listLabel: p.list.label,
16171
+ result: lookup.result,
16172
+ returnCodes: lookup.returnCodes,
16173
+ errorMessage: lookup.errorMessage,
16174
+ runAt: /* @__PURE__ */ new Date()
16175
+ }
16176
+ },
16177
+ { upsert: true }
16178
+ );
16179
+ }
16180
+ let idx = 0;
16181
+ await Promise.all(
16182
+ Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
16183
+ while (idx < duePairs.length) {
16184
+ const my = idx++;
16185
+ await processPair(duePairs[my]);
16186
+ }
16187
+ })
16188
+ );
16189
+ return { ran: true, totalChecks: duePairs.length, listedCount };
16190
+ }
16191
+ function collectTargets(ctx, cfg) {
16192
+ const domains = /* @__PURE__ */ new Set();
16193
+ const registry = ctx.config.senderDomains;
16194
+ if (registry) {
16195
+ for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
16196
+ }
16197
+ const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
16198
+ if (fromDomain) domains.add(fromDomain);
16199
+ const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
16200
+ if (txnDomain) domains.add(txnDomain);
16201
+ return {
16202
+ domains: Array.from(domains),
16203
+ ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
16204
+ };
16205
+ }
16206
+ function buildQueryName(target, kind, listHost) {
16207
+ if (kind === "ip") {
16208
+ const v = net__default.default.isIP(target);
16209
+ if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
16210
+ if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
16211
+ return null;
15945
16212
  }
16213
+ const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
16214
+ return `${domain}.${listHost}`;
16215
+ }
16216
+ var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
16217
+ async function queryDnsbl(resolver, query) {
16218
+ try {
16219
+ const records = await resolver.resolve4(query);
16220
+ return interpretRecords(records);
16221
+ } catch (err) {
16222
+ const code = err?.code;
16223
+ if (code === "ENOTFOUND" || code === "ENODATA") {
16224
+ return { result: "clean", returnCodes: [], errorMessage: null };
16225
+ }
16226
+ if (code && TRANSIENT_DNS_CODES.has(code)) {
16227
+ return {
16228
+ transient: true,
16229
+ result: "error",
16230
+ returnCodes: [],
16231
+ errorMessage: String(err?.message ?? err)
16232
+ };
16233
+ }
16234
+ return {
16235
+ result: "error",
16236
+ returnCodes: [],
16237
+ errorMessage: String(err?.message ?? err)
16238
+ };
16239
+ }
16240
+ }
16241
+ function interpretRecords(records) {
16242
+ if (!records || records.length === 0) {
16243
+ return { result: "clean", returnCodes: [], errorMessage: null };
16244
+ }
16245
+ const errorish = records.filter((r) => r.startsWith("127.255.255."));
16246
+ if (errorish.length === records.length) {
16247
+ return {
16248
+ result: "error",
16249
+ returnCodes: records,
16250
+ errorMessage: `list returned reserved code(s): ${records.join(", ")}`
16251
+ };
16252
+ }
16253
+ const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
16254
+ if (listed.length > 0) {
16255
+ return { result: "listed", returnCodes: records, errorMessage: null };
16256
+ }
16257
+ return { result: "clean", returnCodes: records, errorMessage: null };
16258
+ }
16259
+ function reverseIPv4(ip) {
16260
+ return ip.split(".").reverse().join(".");
16261
+ }
16262
+ function reverseIPv6Nibbles(ip) {
16263
+ const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
16264
+ let normalized = ip;
16265
+ if (v4Match) {
16266
+ const octets = v4Match[1].split(".").map((o) => Number(o));
16267
+ if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
16268
+ const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
16269
+ const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
16270
+ normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
16271
+ }
16272
+ }
16273
+ const sides = normalized.split("::");
16274
+ let groups;
16275
+ if (sides.length === 1) {
16276
+ groups = sides[0].split(":");
16277
+ } else {
16278
+ const left = sides[0].split(":").filter(Boolean);
16279
+ const right = sides[1].split(":").filter(Boolean);
16280
+ const missing = 8 - left.length - right.length;
16281
+ groups = [...left, ...Array(missing).fill("0"), ...right];
16282
+ }
16283
+ if (groups.length !== 8) {
16284
+ throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
16285
+ }
16286
+ const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
16287
+ return padded.split("").reverse().join(".");
16288
+ }
16289
+ function registrableDomain(domain) {
16290
+ const parsed = psl__default.default.parse(domain);
16291
+ if ("domain" in parsed && parsed.domain) return parsed.domain;
16292
+ return domain;
15946
16293
  }
15947
16294
 
15948
- // src/server/runner/tick.ts
15949
- var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
15950
- async function runTick(ctx) {
16295
+ // src/server/runner/postmaster.ts
16296
+ var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
16297
+ var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
16298
+ var FETCH_TIMEOUT_MS = 15e3;
16299
+ async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
16300
+ const ctl = new AbortController();
16301
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
16302
+ try {
16303
+ return await fetcher(url, { ...init, signal: ctl.signal });
16304
+ } finally {
16305
+ clearTimeout(t);
16306
+ }
16307
+ }
16308
+ function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
16309
+ let cached = null;
16310
+ async function getAccessToken() {
16311
+ if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
16312
+ return cached.accessToken;
16313
+ }
16314
+ const body = new URLSearchParams({
16315
+ client_id: cfg.clientId,
16316
+ client_secret: cfg.clientSecret,
16317
+ refresh_token: cfg.refreshToken,
16318
+ grant_type: "refresh_token"
16319
+ });
16320
+ const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
16321
+ method: "POST",
16322
+ headers: { "content-type": "application/x-www-form-urlencoded" },
16323
+ body: body.toString()
16324
+ });
16325
+ if (!res.ok) {
16326
+ throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
16327
+ }
16328
+ const data = await res.json();
16329
+ cached = {
16330
+ accessToken: data.access_token,
16331
+ expiresAt: Date.now() + data.expires_in * 1e3
16332
+ };
16333
+ return cached.accessToken;
16334
+ }
16335
+ async function authedGet(path) {
16336
+ const token = await getAccessToken();
16337
+ const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path}`, {
16338
+ method: "GET",
16339
+ headers: { authorization: `Bearer ${token}` }
16340
+ });
16341
+ if (!res.ok) {
16342
+ throw new Error(`Postmaster GET ${path} failed: ${res.status} ${await safeText(res)}`);
16343
+ }
16344
+ return await res.json();
16345
+ }
16346
+ return {
16347
+ async listDomains() {
16348
+ const data = await authedGet("/domains");
16349
+ return data.domains ?? [];
16350
+ },
16351
+ async getLatestTrafficStats(domain) {
16352
+ const data = await authedGet(
16353
+ `/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
16354
+ );
16355
+ return data.trafficStats?.[0] ?? null;
16356
+ }
16357
+ };
16358
+ }
16359
+ async function safeText(res) {
16360
+ try {
16361
+ return await res.text();
16362
+ } catch {
16363
+ return "<no body>";
16364
+ }
16365
+ }
16366
+ async function runPostmasterPull(ctx, opts = {}) {
16367
+ const cfg = ctx.config.postmaster;
16368
+ if (!cfg) return { ran: false, reason: "not_configured" };
16369
+ const intervalHours = cfg.intervalHours ?? 24;
16370
+ if (!opts.force && intervalHours <= 0) {
16371
+ return { ran: false, reason: "disabled" };
16372
+ }
16373
+ const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
16374
+ const allDomains = resolveDomains(ctx, cfg);
16375
+ if (allDomains.length === 0) {
16376
+ return { ran: false, reason: "no_domains" };
16377
+ }
16378
+ let domains = allDomains;
16379
+ if (!opts.force) {
16380
+ const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
16381
+ const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
16382
+ const lastByDomain = /* @__PURE__ */ new Map();
16383
+ for (const s of latest) {
16384
+ const ts = new Date(s.fetchedAt).getTime();
16385
+ const cur = lastByDomain.get(s.domain) ?? 0;
16386
+ if (ts > cur) lastByDomain.set(s.domain, ts);
16387
+ }
16388
+ domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
16389
+ if (domains.length === 0) {
16390
+ return { ran: false, reason: "not_due" };
16391
+ }
16392
+ }
16393
+ const fetches = await Promise.all(
16394
+ domains.map(async (domain) => {
16395
+ try {
16396
+ const stat = await client.getLatestTrafficStats(domain);
16397
+ return { domain, stat, error: null };
16398
+ } catch (err) {
16399
+ console.error(`mailery: postmaster fetch failed for ${domain}`, err);
16400
+ return { domain, stat: null, error: err };
16401
+ }
16402
+ })
16403
+ );
16404
+ let fetched = 0;
16405
+ const trippedDomains = [];
16406
+ for (const { domain, stat } of fetches) {
16407
+ if (!stat) continue;
16408
+ const snapshot = toSnapshot(domain, stat);
16409
+ if (!snapshot) continue;
16410
+ fetched++;
16411
+ await ctx.collections.postmasterSnapshots.updateOne(
16412
+ { domain: snapshot.domain, date: snapshot.date },
16413
+ { $set: snapshot },
16414
+ { upsert: true }
16415
+ );
16416
+ if (snapshot.domainReputation === "BAD") {
16417
+ const kindsToTrip = kindsForDomain(ctx, domain);
16418
+ for (const kind of kindsToTrip) {
16419
+ const tripped = await tripBucket(ctx, domain, kind, snapshot);
16420
+ if (tripped) trippedDomains.push(`${domain}|${kind}`);
16421
+ }
16422
+ }
16423
+ }
16424
+ return { ran: true, fetched, trippedDomains };
16425
+ }
16426
+ function kindsForDomain(ctx, domain) {
16427
+ const registry = ctx.config.senderDomains ?? {};
16428
+ const entry = registry[domain.toLowerCase()];
16429
+ if (!entry) return [];
16430
+ if (entry.kind === "both") return ["marketing", "transactional"];
16431
+ return [entry.kind];
16432
+ }
16433
+ function resolveDomains(ctx, cfg) {
16434
+ if (cfg.domains && cfg.domains.length > 0) {
16435
+ return cfg.domains.map((d) => d.toLowerCase());
16436
+ }
16437
+ const out = /* @__PURE__ */ new Set();
16438
+ const registry = ctx.config.senderDomains;
16439
+ if (registry) {
16440
+ for (const d of Object.keys(registry)) out.add(d.toLowerCase());
16441
+ }
16442
+ const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
16443
+ if (f) out.add(f);
16444
+ const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
16445
+ if (t) out.add(t);
16446
+ return Array.from(out);
16447
+ }
16448
+ function toSnapshot(domain, stat) {
16449
+ const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
16450
+ if (!m) {
16451
+ console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
16452
+ return null;
16453
+ }
16454
+ const yyyymmdd = m[1];
16455
+ const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
16456
+ return {
16457
+ domain,
16458
+ date,
16459
+ domainReputation: stat.domainReputation ?? null,
16460
+ ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
16461
+ reputation: r.reputation,
16462
+ ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
16463
+ })) : null,
16464
+ userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
16465
+ spfSuccessRatio: stat.spfSuccessRatio ?? null,
16466
+ dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
16467
+ dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
16468
+ outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
16469
+ inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
16470
+ deliveryErrors: stat.deliveryErrors ?? null,
16471
+ spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
16472
+ fetchedAt: /* @__PURE__ */ new Date()
16473
+ };
16474
+ }
16475
+ async function tripBucket(ctx, domain, kind, snapshot) {
16476
+ const id = healthBucketId(domain, kind);
16477
+ const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
16478
+ const prior = await ctx.collections.health.findOne({ _id: id });
16479
+ if (prior?.status === "tripped") {
16480
+ return false;
16481
+ }
15951
16482
  await ctx.collections.health.updateOne(
15952
- { _id: "singleton" },
16483
+ { _id: id },
15953
16484
  {
15954
- $set: { updatedAt: /* @__PURE__ */ new Date() },
16485
+ $set: {
16486
+ status: "tripped",
16487
+ trippedAt: /* @__PURE__ */ new Date(),
16488
+ trippedReason: reason,
16489
+ updatedAt: /* @__PURE__ */ new Date()
16490
+ },
15955
16491
  $setOnInsert: {
15956
- _id: "singleton",
16492
+ _id: id,
16493
+ senderDomain: domain,
16494
+ kind,
15957
16495
  windowStartedAt: /* @__PURE__ */ new Date(),
15958
16496
  windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15959
- status: "healthy",
15960
- trippedAt: null,
15961
- trippedReason: null,
15962
16497
  manuallyResumedAt: null,
15963
16498
  counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15964
16499
  rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15965
16500
  }
15966
16501
  },
15967
16502
  { upsert: true }
15968
- ).catch(() => {
15969
- });
16503
+ );
16504
+ if (ctx.config.onCircuitBreakerTrip) {
16505
+ try {
16506
+ await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
16507
+ } catch {
16508
+ }
16509
+ }
16510
+ return true;
16511
+ }
16512
+
16513
+ // src/server/runner/snds.ts
16514
+ var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
16515
+ var FETCH_TIMEOUT_MS2 = 3e4;
16516
+ async function runSndsPull(ctx, opts = {}) {
16517
+ const cfg = ctx.config.snds;
16518
+ if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
16519
+ const intervalHours = cfg.intervalHours ?? 24;
16520
+ if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
16521
+ if (!opts.force) {
16522
+ const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
16523
+ if (latest[0]) {
16524
+ const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
16525
+ if (ageMs < intervalHours * 60 * 60 * 1e3) {
16526
+ return { ran: false, reason: "not_due" };
16527
+ }
16528
+ }
16529
+ }
16530
+ const fetcher = opts.fetcher ?? globalThis.fetch;
16531
+ const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
16532
+ const ctl = new AbortController();
16533
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
16534
+ let res;
16535
+ try {
16536
+ res = await fetcher(url, { method: "GET", signal: ctl.signal });
16537
+ } catch (err) {
16538
+ throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
16539
+ } finally {
16540
+ clearTimeout(t);
16541
+ }
16542
+ if (!res.ok) {
16543
+ throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
16544
+ }
16545
+ const csv = await res.text();
16546
+ const rows = parseSndsCsv(csv);
16547
+ const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
16548
+ const now = /* @__PURE__ */ new Date();
16549
+ const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
16550
+ updateOne: {
16551
+ filter: { ip: row.ip, activityStart: row.activityStart },
16552
+ update: { $set: { ...row, fetchedAt: now } },
16553
+ upsert: true
16554
+ }
16555
+ }));
16556
+ let persisted = 0;
16557
+ if (ops.length > 0) {
16558
+ const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
16559
+ persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
16560
+ }
16561
+ return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
16562
+ }
16563
+ function redactKey(s, key) {
16564
+ if (!key) return s;
16565
+ return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
16566
+ }
16567
+ async function safeText2(res) {
16568
+ try {
16569
+ return await res.text();
16570
+ } catch {
16571
+ return "<no body>";
16572
+ }
16573
+ }
16574
+ function parseSndsCsv(csv) {
16575
+ const out = [];
16576
+ for (const raw of csv.split(/\r?\n/)) {
16577
+ if (!raw.trim()) continue;
16578
+ const fields = splitCsvRow(raw);
16579
+ if (fields.length < 7) continue;
16580
+ const parsed = parseRow(fields);
16581
+ if (parsed) out.push(parsed);
16582
+ }
16583
+ return out;
16584
+ }
16585
+ function splitCsvRow(row) {
16586
+ const out = [];
16587
+ let cur = "";
16588
+ let inQuotes = false;
16589
+ for (let i = 0; i < row.length; i++) {
16590
+ const ch = row[i];
16591
+ if (inQuotes) {
16592
+ if (ch === '"') {
16593
+ if (row[i + 1] === '"') {
16594
+ cur += '"';
16595
+ i++;
16596
+ } else {
16597
+ inQuotes = false;
16598
+ }
16599
+ } else {
16600
+ cur += ch;
16601
+ }
16602
+ } else if (ch === '"') {
16603
+ inQuotes = true;
16604
+ } else if (ch === ",") {
16605
+ out.push(cur);
16606
+ cur = "";
16607
+ } else {
16608
+ cur += ch;
16609
+ }
16610
+ }
16611
+ out.push(cur);
16612
+ return out.map((f) => f.trim());
16613
+ }
16614
+ function parseRow(fields) {
16615
+ const [
16616
+ ip,
16617
+ activityStart,
16618
+ activityEnd,
16619
+ rcptCommands,
16620
+ dataCommands,
16621
+ messageRecipients,
16622
+ filterResult,
16623
+ complaintRate,
16624
+ trapMessageCount,
16625
+ sampleHelo,
16626
+ sampleMailFrom
16627
+ ] = fields;
16628
+ const start = parseSndsDate(activityStart ?? "");
16629
+ const end = parseSndsDate(activityEnd ?? "");
16630
+ if (!ip || !start || !end) return null;
16631
+ return {
16632
+ ip,
16633
+ activityStart: start,
16634
+ activityEnd: end,
16635
+ rcptCommands: toInt(rcptCommands),
16636
+ dataCommands: toInt(dataCommands),
16637
+ messageRecipients: toInt(messageRecipients),
16638
+ filterResult: normalizeFilter(filterResult ?? ""),
16639
+ complaintRate: parseComplaintRate(complaintRate ?? ""),
16640
+ trapMessageCount: toInt(trapMessageCount),
16641
+ sampleHelo: sampleHelo ? sampleHelo : null,
16642
+ sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
16643
+ };
16644
+ }
16645
+ function toInt(s) {
16646
+ if (!s) return 0;
16647
+ const n = Number(s.replace(/[^\d-]/g, ""));
16648
+ return Number.isFinite(n) ? n : 0;
16649
+ }
16650
+ function normalizeFilter(s) {
16651
+ const u = s.toUpperCase().trim();
16652
+ if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
16653
+ return "UNKNOWN";
16654
+ }
16655
+ function parseComplaintRate(s) {
16656
+ const trimmed = s.trim();
16657
+ if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
16658
+ const cleaned = trimmed.replace(/%/g, "");
16659
+ let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16660
+ if (m) return Number(m[1]) / 100;
16661
+ m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16662
+ if (m) return Number(m[1]) / 100;
16663
+ m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16664
+ if (m) return Number(m[2]) / 100;
16665
+ m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
16666
+ if (m) return Number(m[1]) / 100;
16667
+ return null;
16668
+ }
16669
+ function parseSndsDate(s) {
16670
+ const trimmed = s.trim();
16671
+ if (!trimmed) return null;
16672
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
16673
+ if (m) {
16674
+ const month = Number(m[1]) - 1;
16675
+ const day = Number(m[2]);
16676
+ const year = Number(m[3]);
16677
+ let hour = Number(m[4]);
16678
+ const minute = Number(m[5]);
16679
+ const second = Number(m[6] ?? 0);
16680
+ const meridiem = m[7]?.toUpperCase();
16681
+ if (meridiem === "PM" && hour < 12) hour += 12;
16682
+ if (meridiem === "AM" && hour === 12) hour = 0;
16683
+ const wallMs = Date.UTC(year, month, day, hour, minute, second);
16684
+ if (!Number.isFinite(wallMs)) return null;
16685
+ const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
16686
+ return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
16687
+ }
16688
+ const d = new Date(trimmed);
16689
+ if (!isNaN(d.getTime())) return d;
16690
+ return null;
16691
+ }
16692
+ function isUsPacificDst(year, month0, day) {
16693
+ if (month0 > 2 && month0 < 10) return true;
16694
+ if (month0 < 2 || month0 > 10) return false;
16695
+ if (month0 === 2) {
16696
+ const dst2 = nthSundayOfMonth(year, 2, 2);
16697
+ return day >= dst2;
16698
+ }
16699
+ const dst = nthSundayOfMonth(year, 10, 1);
16700
+ return day < dst;
16701
+ }
16702
+ function nthSundayOfMonth(year, month0, n) {
16703
+ const first = new Date(Date.UTC(year, month0, 1));
16704
+ const dayOfWeek = first.getUTCDay();
16705
+ const firstSunday = 1 + (7 - dayOfWeek) % 7;
16706
+ return firstSunday + (n - 1) * 7;
16707
+ }
16708
+ var _lastPruneAt = 0;
16709
+ var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
16710
+ async function pruneDmarcFailures(ctx, opts = {}) {
16711
+ const cfg = ctx.config.dmarc;
16712
+ const days = cfg?.retentionDays ?? 90;
16713
+ if (days <= 0) return 0;
16714
+ if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
16715
+ _lastPruneAt = Date.now();
16716
+ const cutoff = new Date(Date.now() - days * 864e5);
16717
+ const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
16718
+ return r.deletedCount ?? 0;
16719
+ }
16720
+
16721
+ // src/server/runner/tick.ts
16722
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
16723
+ async function runTick(ctx) {
16724
+ try {
16725
+ await ctx.collections.health.updateOne(
16726
+ { _id: HEALTH_AGG_ID },
16727
+ {
16728
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
16729
+ $setOnInsert: {
16730
+ _id: HEALTH_AGG_ID,
16731
+ senderDomain: null,
16732
+ kind: null,
16733
+ windowStartedAt: /* @__PURE__ */ new Date(),
16734
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
16735
+ status: "healthy",
16736
+ trippedAt: null,
16737
+ trippedReason: null,
16738
+ manuallyResumedAt: null,
16739
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
16740
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
16741
+ }
16742
+ },
16743
+ { upsert: true }
16744
+ );
16745
+ } catch (err) {
16746
+ console.error("mailery: heartbeat write failed", err);
16747
+ }
15970
16748
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15971
16749
  console.error("mailery: triggers scan failed", err);
15972
16750
  });
@@ -15988,6 +16766,20 @@ async function runTick(ctx) {
15988
16766
  await promoteSoftBounces(ctx).catch((err) => {
15989
16767
  console.error("mailery: soft-bounce promotion failed", err);
15990
16768
  });
16769
+ await Promise.all([
16770
+ runDnsblChecks(ctx).catch((err) => {
16771
+ console.error("mailery: dnsbl checks failed", err);
16772
+ }),
16773
+ runPostmasterPull(ctx).catch((err) => {
16774
+ console.error("mailery: postmaster pull failed", err);
16775
+ }),
16776
+ runSndsPull(ctx).catch((err) => {
16777
+ console.error("mailery: snds pull failed", err);
16778
+ }),
16779
+ pruneDmarcFailures(ctx).catch((err) => {
16780
+ console.error("mailery: dmarc prune failed", err);
16781
+ })
16782
+ ]);
15991
16783
  }
15992
16784
  async function sweepStrandedSends(ctx) {
15993
16785
  const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
@@ -16045,6 +16837,10 @@ async function processScheduledBroadcasts2(ctx) {
16045
16837
  }
16046
16838
 
16047
16839
  // src/server/runner/webhook.ts
16840
+ function dimsFromSend(send) {
16841
+ if (!send) return null;
16842
+ return { fromEmail: send.fromEmail, kind: send.kind };
16843
+ }
16048
16844
  async function applyWebhookEvent(event, ctx) {
16049
16845
  const send = await ctx.collections.sends.findOne(
16050
16846
  event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
@@ -16058,7 +16854,7 @@ async function applyWebhookEvent(event, ctx) {
16058
16854
  { $set: { status: "delivered", deliveredAt: event.occurredAt } }
16059
16855
  );
16060
16856
  }
16061
- await recordHealthCounter(ctx, "delivered");
16857
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
16062
16858
  break;
16063
16859
  case "open":
16064
16860
  if (send) {
@@ -16113,8 +16909,11 @@ async function applyWebhookEvent(event, ctx) {
16113
16909
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
16114
16910
  );
16115
16911
  }
16116
- await recordHealthCounter(ctx, "bounced");
16117
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
16912
+ {
16913
+ const dims = dimsFromSend(send);
16914
+ await recordHealthCounter(ctx, "bounced", dims);
16915
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
16916
+ }
16118
16917
  break;
16119
16918
  }
16120
16919
  case "complaint":
@@ -16130,7 +16929,7 @@ async function applyWebhookEvent(event, ctx) {
16130
16929
  { emailAtSubscribe: event.email },
16131
16930
  { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
16132
16931
  );
16133
- await recordHealthCounter(ctx, "complained");
16932
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
16134
16933
  break;
16135
16934
  case "unsubscribe":
16136
16935
  if (send) {
@@ -16199,7 +16998,8 @@ var Mailer = class _Mailer {
16199
16998
  providers: this.providers,
16200
16999
  queues: this.queues,
16201
17000
  config: this.config,
16202
- handlebarsHelpers: this.config.handlebarsHelpers
17001
+ handlebarsHelpers: this.config.handlebarsHelpers,
17002
+ audit: (entry) => this.audit(entry)
16203
17003
  };
16204
17004
  }
16205
17005
  /**