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.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 net = tslib_1.__importStar(__require("net"));
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 = net.createServer();
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 net = __importStar2(__require("net"));
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 net.Socket({ writable: false });
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 net = __importStar2(__require("net"));
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 && !net.isIP(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 = net.connect(this.connectOpts);
9474
+ socket = net2.connect(this.connectOpts);
9472
9475
  }
9473
9476
  const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders };
9474
- const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.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 net.Socket({ writable: false });
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
- async function recordHealthCounter(ctx, counter2, by = 1) {
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: "singleton" },
15185
+ { _id },
15072
15186
  {
15073
15187
  $inc: { [`counters.${counter2}`]: by },
15074
15188
  $setOnInsert: {
15075
- _id: "singleton",
15189
+ _id,
15190
+ senderDomain,
15191
+ kind,
15076
15192
  windowStartedAt: /* @__PURE__ */ new Date(),
15077
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15193
+ windowDurationMs: windowMs,
15078
15194
  status: "healthy",
15079
15195
  trippedAt: null,
15080
15196
  trippedReason: null,
15081
15197
  manuallyResumedAt: null,
15082
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
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 doc = await ctx.collections.health.findOne({ _id: "singleton" });
15093
- if (!doc) return;
15094
- const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15095
- if (windowAge > windowMs && doc.status !== "tripped") {
15096
- await ctx.collections.health.updateOne(
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 c = doc.counters;
15111
- const total = c.sent || 1;
15112
- const rates = {
15113
- bounceRate: c.bounced / total,
15114
- hardBounceRate: c.hardBounced / total,
15115
- complaintRate: c.complained / total,
15116
- failureRate: c.failedToSend / total
15117
- };
15118
- await ctx.collections.health.updateOne(
15119
- { _id: "singleton" },
15120
- { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15121
- );
15122
- if (c.sent < cb.minSendsBeforeEval) return;
15123
- if (doc.status === "tripped") return;
15124
- let trippedReason = null;
15125
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15126
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15127
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15128
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15129
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15130
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15131
- }
15132
- if (trippedReason) {
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: "singleton" },
15135
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15242
+ { _id: doc._id },
15243
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15136
15244
  );
15137
- if (ctx.config.onCircuitBreakerTrip) {
15138
- try {
15139
- await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
15140
- } catch {
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
- return;
15144
- }
15145
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15146
- if (doc.status !== "degraded") {
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: "singleton" },
15149
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
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 health = await ctx.collections.health.findOne({ _id: "singleton" });
15243
- if (health?.status === "tripped") {
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
- { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
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(ctx, "hardBounced");
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/tick.ts
15939
- var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
15940
- async function runTick(ctx) {
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: "singleton" },
16470
+ { _id: id },
15943
16471
  {
15944
- $set: { updatedAt: /* @__PURE__ */ new Date() },
16472
+ $set: {
16473
+ status: "tripped",
16474
+ trippedAt: /* @__PURE__ */ new Date(),
16475
+ trippedReason: reason,
16476
+ updatedAt: /* @__PURE__ */ new Date()
16477
+ },
15945
16478
  $setOnInsert: {
15946
- _id: "singleton",
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
- ).catch(() => {
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
- await recordHealthCounter(ctx, "bounced");
16107
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
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
  /**