mailery 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -1,11 +1,14 @@
1
+ import { z } from 'zod';
1
2
  import { MongoClient, ObjectId } from 'mongodb';
2
3
  import crypto2 from 'crypto';
3
4
  import sgMail from '@sendgrid/mail';
4
- import { z } from 'zod';
5
5
  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;
@@ -47,6 +50,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
47
50
  ));
48
51
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
49
52
 
53
+ // src/server/adapters/vars.ts
54
+ var vars_exports = {};
55
+ __export(vars_exports, {
56
+ RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
57
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
58
+ defineVars: () => defineVars,
59
+ resolveVars: () => resolveVars,
60
+ varsJsonSchema: () => varsJsonSchema
61
+ });
62
+ function defineVars(adapter) {
63
+ return adapter;
64
+ }
65
+ function assertNoReservedVarKeys(adapter) {
66
+ const json = varsJsonSchema(adapter);
67
+ const props = json && typeof json === "object" ? json.properties : void 0;
68
+ if (!props) return;
69
+ const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
70
+ if (clashes.length > 0) {
71
+ throw new Error(
72
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
73
+ );
74
+ }
75
+ }
76
+ function varsJsonSchema(adapter) {
77
+ return z.toJSONSchema(adapter.schema, { io: "output" });
78
+ }
79
+ async function resolveVars(adapter, contact, info) {
80
+ if (!adapter) return {};
81
+ const resolved = await adapter.resolve(contact, info);
82
+ if (!resolved || typeof resolved !== "object") return {};
83
+ const out = { ...resolved };
84
+ for (const k of RESERVED_VAR_KEYS) delete out[k];
85
+ return out;
86
+ }
87
+ var RESERVED_VAR_KEYS;
88
+ var init_vars = __esm({
89
+ "src/server/adapters/vars.ts"() {
90
+ RESERVED_VAR_KEYS = [
91
+ "contact",
92
+ "vars",
93
+ "event",
94
+ "unsubscribeUrl",
95
+ "viewInBrowserUrl",
96
+ "preferenceCenterUrl",
97
+ "senderAddress"
98
+ ];
99
+ }
100
+ });
101
+
50
102
  // src/server/adapters/mongo.ts
51
103
  var mongo_exports = {};
52
104
  __export(mongo_exports, {
@@ -2989,19 +3041,19 @@ var require_range = __commonJS({
2989
3041
  var replaceCaret = (comp, options2) => {
2990
3042
  debug("caret", comp, options2);
2991
3043
  const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
2992
- const z2 = options2.includePrerelease ? "-0" : "";
3044
+ const z3 = options2.includePrerelease ? "-0" : "";
2993
3045
  return comp.replace(r, (_, M, m, p, pr) => {
2994
3046
  debug("caret", comp, _, M, m, p, pr);
2995
3047
  let ret;
2996
3048
  if (isX(M)) {
2997
3049
  ret = "";
2998
3050
  } else if (isX(m)) {
2999
- ret = `>=${M}.0.0${z2} <${+M + 1}.0.0-0`;
3051
+ ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
3000
3052
  } else if (isX(p)) {
3001
3053
  if (M === "0") {
3002
- ret = `>=${M}.${m}.0${z2} <${M}.${+m + 1}.0-0`;
3054
+ ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
3003
3055
  } else {
3004
- ret = `>=${M}.${m}.0${z2} <${+M + 1}.0.0-0`;
3056
+ ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
3005
3057
  }
3006
3058
  } else if (pr) {
3007
3059
  debug("replaceCaret pr", pr);
@@ -3018,9 +3070,9 @@ var require_range = __commonJS({
3018
3070
  debug("no pr");
3019
3071
  if (M === "0") {
3020
3072
  if (m === "0") {
3021
- ret = `>=${M}.${m}.${p}${z2} <${M}.${m}.${+p + 1}-0`;
3073
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
3022
3074
  } else {
3023
- ret = `>=${M}.${m}.${p}${z2} <${M}.${+m + 1}.0-0`;
3075
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
3024
3076
  }
3025
3077
  } else {
3026
3078
  ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
@@ -4155,7 +4207,7 @@ var require_getport = __commonJS({
4155
4207
  exports.tryPort = tryPort;
4156
4208
  exports.resetPortsCache = resetPortsCache;
4157
4209
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
4158
- var net = tslib_1.__importStar(__require("net"));
4210
+ var net2 = tslib_1.__importStar(__require("net"));
4159
4211
  var debug_1 = tslib_1.__importDefault(require_src());
4160
4212
  var log = (0, debug_1.default)("MongoMS:GetPort");
4161
4213
  exports.MIN_PORT = 1024;
@@ -4203,7 +4255,7 @@ var require_getport = __commonJS({
4203
4255
  }
4204
4256
  function tryPort(port) {
4205
4257
  return new Promise((res, rej) => {
4206
- const server = net.createServer();
4258
+ const server = net2.createServer();
4207
4259
  if (typeof server.unref === "function") {
4208
4260
  server.unref();
4209
4261
  }
@@ -9169,7 +9221,7 @@ var require_dist = __commonJS({
9169
9221
  };
9170
9222
  Object.defineProperty(exports, "__esModule", { value: true });
9171
9223
  exports.Agent = void 0;
9172
- var net = __importStar2(__require("net"));
9224
+ var net2 = __importStar2(__require("net"));
9173
9225
  var http = __importStar2(__require("http"));
9174
9226
  var https_1 = __require("https");
9175
9227
  __exportStar2(require_helpers(), exports);
@@ -9209,7 +9261,7 @@ var require_dist = __commonJS({
9209
9261
  if (!this.sockets[name]) {
9210
9262
  this.sockets[name] = [];
9211
9263
  }
9212
- const fakeSocket = new net.Socket({ writable: false });
9264
+ const fakeSocket = new net2.Socket({ writable: false });
9213
9265
  this.sockets[name].push(fakeSocket);
9214
9266
  this.totalSocketCount++;
9215
9267
  return fakeSocket;
@@ -9419,7 +9471,7 @@ var require_dist2 = __commonJS({
9419
9471
  };
9420
9472
  Object.defineProperty(exports, "__esModule", { value: true });
9421
9473
  exports.HttpsProxyAgent = void 0;
9422
- var net = __importStar2(__require("net"));
9474
+ var net2 = __importStar2(__require("net"));
9423
9475
  var tls = __importStar2(__require("tls"));
9424
9476
  var assert_1 = __importDefault2(__require("assert"));
9425
9477
  var debug_1 = __importDefault2(require_src());
@@ -9428,7 +9480,7 @@ var require_dist2 = __commonJS({
9428
9480
  var parse_proxy_response_1 = require_parse_proxy_response();
9429
9481
  var debug = (0, debug_1.default)("https-proxy-agent");
9430
9482
  var setServernameFromNonIpHost = (options2) => {
9431
- if (options2.servername === void 0 && options2.host && !net.isIP(options2.host)) {
9483
+ if (options2.servername === void 0 && options2.host && !net2.isIP(options2.host)) {
9432
9484
  return {
9433
9485
  ...options2,
9434
9486
  servername: options2.host
@@ -9468,10 +9520,10 @@ var require_dist2 = __commonJS({
9468
9520
  socket = tls.connect(setServernameFromNonIpHost(this.connectOpts));
9469
9521
  } else {
9470
9522
  debug("Creating `net.Socket`: %o", this.connectOpts);
9471
- socket = net.connect(this.connectOpts);
9523
+ socket = net2.connect(this.connectOpts);
9472
9524
  }
9473
9525
  const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders };
9474
- const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
9526
+ const host = net2.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
9475
9527
  let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r
9476
9528
  `;
9477
9529
  if (proxy.username || proxy.password) {
@@ -9504,7 +9556,7 @@ var require_dist2 = __commonJS({
9504
9556
  return socket;
9505
9557
  }
9506
9558
  socket.destroy();
9507
- const fakeSocket = new net.Socket({ writable: false });
9559
+ const fakeSocket = new net2.Socket({ writable: false });
9508
9560
  fakeSocket.readable = true;
9509
9561
  req.once("socket", (s) => {
9510
9562
  debug("Replaying proxy buffer for failed request");
@@ -14140,6 +14192,12 @@ var tagInputSchema = z.object({
14140
14192
  externalId: externalIdSchema,
14141
14193
  tag: z.string().min(1).max(128)
14142
14194
  });
14195
+ var abortFlowInputSchema = z.object({
14196
+ flowSlug: slugSchema,
14197
+ externalId: externalIdSchema,
14198
+ reason: z.string().min(1).max(200).optional()
14199
+ });
14200
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14143
14201
  var sendOneOffInputSchema = z.object({
14144
14202
  templateSlug: slugSchema,
14145
14203
  externalId: externalIdSchema,
@@ -14169,7 +14227,13 @@ var flowStepSchema = z.lazy(
14169
14227
  type: z.literal("send"),
14170
14228
  templateSlug: slugSchema,
14171
14229
  providerOverride: z.string().optional(),
14172
- vars: z.record(z.string(), z.unknown()).optional()
14230
+ vars: z.record(z.string(), z.unknown()).optional(),
14231
+ delivery: z.object({
14232
+ weekdaysOnly: z.boolean().optional(),
14233
+ timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
14234
+ useContactTimezone: z.boolean().optional(),
14235
+ timezone: z.string().optional()
14236
+ }).optional()
14173
14237
  }),
14174
14238
  z.object({
14175
14239
  type: z.literal("tag"),
@@ -14253,6 +14317,17 @@ var predicateSchema = z.lazy(
14253
14317
  );
14254
14318
 
14255
14319
  // src/server/config.ts
14320
+ var DEFAULT_DOMAIN_DNSBL_LISTS = [
14321
+ { host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
14322
+ { host: "multi.surbl.org", label: "SURBL" },
14323
+ { host: "multi.uribl.com", label: "URIBL" }
14324
+ ];
14325
+ var DEFAULT_IP_DNSBL_LISTS = [
14326
+ { host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
14327
+ { host: "b.barracudacentral.org", label: "Barracuda" },
14328
+ { host: "dnsbl.sorbs.net", label: "SORBS" },
14329
+ { host: "bl.spamcop.net", label: "SpamCop" }
14330
+ ];
14256
14331
  var DEFAULTS = {
14257
14332
  collectionPrefix: "mailer_",
14258
14333
  requireDoubleOptIn: false,
@@ -14313,6 +14388,10 @@ function resolveConfig(c) {
14313
14388
  }
14314
14389
 
14315
14390
  // src/server/models/index.ts
14391
+ var HEALTH_AGG_ID = "agg";
14392
+ function healthBucketId(senderDomain, kind) {
14393
+ return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
14394
+ }
14316
14395
  function getCollections(db, prefix = "mailer_") {
14317
14396
  return {
14318
14397
  subscriptions: db.collection(`${prefix}subscriptions`),
@@ -14330,7 +14409,14 @@ function getCollections(db, prefix = "mailer_") {
14330
14409
  auditLog: db.collection(`${prefix}audit_log`),
14331
14410
  webhookEvents: db.collection(`${prefix}webhook_events`),
14332
14411
  health: db.collection(`${prefix}health`),
14333
- contactTags: db.collection(`${prefix}contact_tags`)
14412
+ contactTags: db.collection(`${prefix}contact_tags`),
14413
+ dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
14414
+ postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
14415
+ sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
14416
+ dmarcReports: db.collection(`${prefix}dmarc_reports`),
14417
+ dmarcFailures: db.collection(`${prefix}dmarc_failures`),
14418
+ dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
14419
+ mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
14334
14420
  };
14335
14421
  }
14336
14422
  async function ensureIndexes(db, prefix = "mailer_") {
@@ -14402,8 +14488,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
14402
14488
  c.contactTags.createIndexes([
14403
14489
  { key: { externalId: 1, tag: 1 }, unique: true },
14404
14490
  { key: { tag: 1 } }
14491
+ ]),
14492
+ c.health.createIndexes([
14493
+ { key: { senderDomain: 1, kind: 1 } },
14494
+ { key: { status: 1 } },
14495
+ // setup-status reads the most-recently-touched health doc as a heartbeat.
14496
+ { key: { updatedAt: -1 } }
14497
+ ]),
14498
+ c.dnsblChecks.createIndexes([
14499
+ { key: { target: 1, list: 1 }, unique: true },
14500
+ // Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
14501
+ { key: { result: 1, target: 1, list: 1 } },
14502
+ // TTL — stale rows for targets the operator removed disappear after
14503
+ // 60 days without manual cleanup. The puller refreshes runAt on
14504
+ // every active target so existing targets stay indefinitely.
14505
+ { key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
14506
+ ]),
14507
+ c.postmasterSnapshots.createIndexes([
14508
+ { key: { domain: 1, date: 1 }, unique: true },
14509
+ { key: { domain: 1, fetchedAt: -1 } },
14510
+ { key: { domainReputation: 1 } }
14511
+ ]),
14512
+ c.sndsSnapshots.createIndexes([
14513
+ { key: { ip: 1, activityStart: 1 }, unique: true },
14514
+ { key: { ip: 1, fetchedAt: -1 } },
14515
+ { key: { filterResult: 1 } }
14516
+ ]),
14517
+ c.dmarcReports.createIndexes([
14518
+ { key: { reportId: 1, orgName: 1 }, unique: true },
14519
+ { key: { domain: 1, rangeEnd: -1 } },
14520
+ // Cross-domain "most recent reports" queries scan a lot without this.
14521
+ { key: { rangeEnd: -1 } },
14522
+ { key: { receivedAt: -1 } }
14523
+ ]),
14524
+ c.dmarcFailures.createIndexes([
14525
+ { key: { reportId: 1, sourceIp: 1 }, unique: true },
14526
+ { key: { domain: 1, day: -1 } },
14527
+ { key: { sourceIp: 1, day: -1 } },
14528
+ { key: { receivedAt: 1 } }
14529
+ // for retention pruning
14530
+ ]),
14531
+ c.dmarcSourceTags.createIndexes([
14532
+ { key: { ip: 1 }, unique: true }
14533
+ ]),
14534
+ c.mailTesterScores.createIndexes([
14535
+ { key: { contentKey: 1 }, unique: true },
14536
+ { key: { templateSlug: 1, fetchedAt: -1 } },
14537
+ // TTL — Mongo auto-deletes expired scores so we never serve stale data.
14538
+ { key: { expiresAt: 1 }, expireAfterSeconds: 0 }
14405
14539
  ])
14406
14540
  ]);
14541
+ await c.health.deleteOne({ _id: "singleton" }).catch(() => {
14542
+ });
14407
14543
  }
14408
14544
  var EventRegistry = class {
14409
14545
  policies = /* @__PURE__ */ new Map();
@@ -14416,6 +14552,9 @@ var EventRegistry = class {
14416
14552
  policy(name) {
14417
14553
  return this.policies.get(name);
14418
14554
  }
14555
+ list() {
14556
+ return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
14557
+ }
14419
14558
  /**
14420
14559
  * Derive a dedupeKey for an event call. Returns null when no policy is
14421
14560
  * registered AND no key was passed — caller should throw.
@@ -14829,6 +14968,7 @@ async function tryEnterFlow(flow, event, ctx) {
14829
14968
  flowSlug: flow.slug,
14830
14969
  flowVersion: flow.version,
14831
14970
  emailAtEntry: sub.emailAtSubscribe,
14971
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14832
14972
  enteredAt: /* @__PURE__ */ new Date(),
14833
14973
  status: "active",
14834
14974
  currentStepIndex: 0,
@@ -14926,6 +15066,105 @@ function effectiveLowerBound(ctx, opts) {
14926
15066
  }
14927
15067
  return null;
14928
15068
  }
15069
+
15070
+ // src/server/runner/delivery-window.ts
15071
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
15072
+ function computeDeliveryTime(now, window2, contactTimezone) {
15073
+ const tz = pickTimezone(window2, contactTimezone);
15074
+ let candidate = now;
15075
+ if (window2.timeOfDay) {
15076
+ const [hh, mm] = window2.timeOfDay.split(":").map(Number);
15077
+ const local = localParts(candidate, tz);
15078
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
15079
+ if (candidate.getTime() < todaySlot.getTime()) {
15080
+ candidate = todaySlot;
15081
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
15082
+ const next = addLocalDays(local, 1);
15083
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
15084
+ }
15085
+ }
15086
+ if (window2.weekdaysOnly) {
15087
+ for (let guard = 0; guard < 3; guard++) {
15088
+ const local = localParts(candidate, tz);
15089
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
15090
+ const shift = local.weekday === "Sat" ? 2 : 1;
15091
+ const moved = addLocalDays(local, shift);
15092
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
15093
+ }
15094
+ }
15095
+ return candidate;
15096
+ }
15097
+ function pickTimezone(window2, contactTimezone) {
15098
+ const candidates = [
15099
+ window2.useContactTimezone ? contactTimezone : void 0,
15100
+ window2.timezone,
15101
+ "UTC"
15102
+ ];
15103
+ for (const tz of candidates) {
15104
+ if (tz && isValidTimezone(tz)) return tz;
15105
+ }
15106
+ return "UTC";
15107
+ }
15108
+ var validatedZones = /* @__PURE__ */ new Map();
15109
+ function isValidTimezone(tz) {
15110
+ const cached = validatedZones.get(tz);
15111
+ if (cached !== void 0) return cached;
15112
+ let ok = true;
15113
+ try {
15114
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
15115
+ } catch {
15116
+ ok = false;
15117
+ }
15118
+ validatedZones.set(tz, ok);
15119
+ return ok;
15120
+ }
15121
+ var partFormatters = /* @__PURE__ */ new Map();
15122
+ function formatterFor(tz) {
15123
+ let f = partFormatters.get(tz);
15124
+ if (!f) {
15125
+ f = new Intl.DateTimeFormat("en-US", {
15126
+ timeZone: tz,
15127
+ year: "numeric",
15128
+ month: "2-digit",
15129
+ day: "2-digit",
15130
+ hour: "2-digit",
15131
+ minute: "2-digit",
15132
+ second: "2-digit",
15133
+ weekday: "short",
15134
+ hour12: false
15135
+ });
15136
+ partFormatters.set(tz, f);
15137
+ }
15138
+ return f;
15139
+ }
15140
+ function localParts(date, tz) {
15141
+ const parts = {};
15142
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
15143
+ return {
15144
+ y: Number(parts.year),
15145
+ mo: Number(parts.month),
15146
+ d: Number(parts.day),
15147
+ hh: Number(parts.hour) % 24,
15148
+ // Intl emits '24' for midnight in some locales
15149
+ mi: Number(parts.minute),
15150
+ ss: Number(parts.second),
15151
+ weekday: parts.weekday
15152
+ };
15153
+ }
15154
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
15155
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
15156
+ for (let i = 0; i < 2; i++) {
15157
+ const p = localParts(new Date(ts), tz);
15158
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
15159
+ const offset = asUtc - ts;
15160
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
15161
+ }
15162
+ return new Date(ts);
15163
+ }
15164
+ function addLocalDays(p, days) {
15165
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
15166
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
15167
+ }
14929
15168
  async function compileTemplate(mjml) {
14930
15169
  const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
14931
15170
  const plainText = derivePlaintext(out.html);
@@ -15043,6 +15282,9 @@ function makeHandlebars(extra) {
15043
15282
  return hb;
15044
15283
  }
15045
15284
 
15285
+ // src/server/runner/send.ts
15286
+ init_vars();
15287
+
15046
15288
  // src/server/runner/suppression.ts
15047
15289
  var SCOPES_BY_KIND = {
15048
15290
  marketing: ["all", "marketing"],
@@ -15065,21 +15307,59 @@ async function isSuppressed(collections, email, kind) {
15065
15307
  return { suppressed: false };
15066
15308
  }
15067
15309
 
15310
+ // src/server/templates/sender-domain.ts
15311
+ function extractDomain(email) {
15312
+ if (typeof email !== "string") return null;
15313
+ const at = email.lastIndexOf("@");
15314
+ if (at <= 0 || at === email.length - 1) return null;
15315
+ return email.slice(at + 1).toLowerCase().trim();
15316
+ }
15317
+
15068
15318
  // src/server/runner/health.ts
15069
- async function recordHealthCounter(ctx, counter2, by = 1) {
15319
+ var ZERO_COUNTERS = {
15320
+ sent: 0,
15321
+ delivered: 0,
15322
+ bounced: 0,
15323
+ hardBounced: 0,
15324
+ softBounced: 0,
15325
+ complained: 0,
15326
+ failedToSend: 0
15327
+ };
15328
+ var ZERO_RATES = {
15329
+ bounceRate: 0,
15330
+ hardBounceRate: 0,
15331
+ complaintRate: 0,
15332
+ failureRate: 0
15333
+ };
15334
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15335
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
15336
+ const writes = [];
15337
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
15338
+ if (dims) {
15339
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
15340
+ if (domain) {
15341
+ const id = healthBucketId(domain, dims.kind);
15342
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
15343
+ }
15344
+ }
15345
+ await Promise.all(writes);
15346
+ }
15347
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
15070
15348
  await ctx.collections.health.updateOne(
15071
- { _id: "singleton" },
15349
+ { _id },
15072
15350
  {
15073
15351
  $inc: { [`counters.${counter2}`]: by },
15074
15352
  $setOnInsert: {
15075
- _id: "singleton",
15353
+ _id,
15354
+ senderDomain,
15355
+ kind,
15076
15356
  windowStartedAt: /* @__PURE__ */ new Date(),
15077
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15357
+ windowDurationMs: windowMs,
15078
15358
  status: "healthy",
15079
15359
  trippedAt: null,
15080
15360
  trippedReason: null,
15081
15361
  manuallyResumedAt: null,
15082
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15362
+ rates: { ...ZERO_RATES }
15083
15363
  },
15084
15364
  $set: { updatedAt: /* @__PURE__ */ new Date() }
15085
15365
  },
@@ -15089,73 +15369,107 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
15089
15369
  async function evaluateHealth(ctx) {
15090
15370
  const cb = ctx.config.circuitBreaker;
15091
15371
  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;
15372
+ const docs = await ctx.collections.health.find({}).toArray();
15373
+ if (docs.length === 0) return;
15374
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
15375
+ if (!hasAgg) {
15376
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
15109
15377
  }
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) {
15378
+ for (const doc of docs) {
15379
+ const isAgg = doc._id === HEALTH_AGG_ID;
15380
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15381
+ if (windowAge > windowMs && doc.status !== "tripped") {
15382
+ await ctx.collections.health.updateOne(
15383
+ { _id: doc._id },
15384
+ {
15385
+ $set: {
15386
+ windowStartedAt: /* @__PURE__ */ new Date(),
15387
+ windowDurationMs: windowMs,
15388
+ counters: { ...ZERO_COUNTERS },
15389
+ rates: { ...ZERO_RATES },
15390
+ status: "healthy",
15391
+ updatedAt: /* @__PURE__ */ new Date()
15392
+ }
15393
+ }
15394
+ );
15395
+ continue;
15396
+ }
15397
+ const c = doc.counters;
15398
+ const total = c.sent || 1;
15399
+ const rates = {
15400
+ bounceRate: c.bounced / total,
15401
+ hardBounceRate: c.hardBounced / total,
15402
+ complaintRate: c.complained / total,
15403
+ failureRate: c.failedToSend / total
15404
+ };
15133
15405
  await ctx.collections.health.updateOne(
15134
- { _id: "singleton" },
15135
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15406
+ { _id: doc._id },
15407
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15136
15408
  );
15137
- if (ctx.config.onCircuitBreakerTrip) {
15138
- try {
15139
- await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
15140
- } catch {
15409
+ if (isAgg) continue;
15410
+ if (c.sent < cb.minSendsBeforeEval) continue;
15411
+ if (doc.status === "tripped") continue;
15412
+ let trippedReason = null;
15413
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15414
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15415
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15416
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15417
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15418
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15419
+ }
15420
+ if (trippedReason) {
15421
+ const result = await ctx.collections.health.updateOne(
15422
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
15423
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15424
+ );
15425
+ if (result.modifiedCount > 0) {
15426
+ if (ctx.audit) {
15427
+ try {
15428
+ await ctx.audit({
15429
+ actor: "system:circuit-breaker",
15430
+ action: "health.trip",
15431
+ resource: {
15432
+ collection: "mailer_health",
15433
+ id: String(doc._id),
15434
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
15435
+ },
15436
+ diffSummary: trippedReason
15437
+ });
15438
+ } catch {
15439
+ }
15440
+ }
15441
+ if (ctx.config.onCircuitBreakerTrip) {
15442
+ try {
15443
+ await ctx.config.onCircuitBreakerTrip({
15444
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
15445
+ rates
15446
+ });
15447
+ } catch {
15448
+ }
15449
+ }
15141
15450
  }
15451
+ continue;
15142
15452
  }
15143
- return;
15144
- }
15145
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15146
- if (doc.status !== "degraded") {
15453
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15454
+ if (doc.status !== "degraded") {
15455
+ await ctx.collections.health.updateOne(
15456
+ { _id: doc._id },
15457
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15458
+ );
15459
+ }
15460
+ } else if (doc.status === "degraded") {
15147
15461
  await ctx.collections.health.updateOne(
15148
- { _id: "singleton" },
15149
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15462
+ { _id: doc._id },
15463
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15150
15464
  );
15151
15465
  }
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
15466
  }
15158
15467
  }
15468
+ async function getBucketStatus(ctx, fromEmail, kind) {
15469
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
15470
+ const id = healthBucketId(domain, kind);
15471
+ return ctx.collections.health.findOne({ _id: id });
15472
+ }
15159
15473
 
15160
15474
  // src/server/runner/send.ts
15161
15475
  async function handleSend(run, step, contact, flow, ctx) {
@@ -15239,8 +15553,8 @@ async function dispatchSend(sendId, ctx) {
15239
15553
  return;
15240
15554
  }
15241
15555
  if (send.kind === "marketing") {
15242
- const health = await ctx.collections.health.findOne({ _id: "singleton" });
15243
- if (health?.status === "tripped") {
15556
+ const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
15557
+ if (bucket?.status === "tripped") {
15244
15558
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
15245
15559
  return;
15246
15560
  }
@@ -15251,13 +15565,29 @@ async function dispatchSend(sendId, ctx) {
15251
15565
  return;
15252
15566
  }
15253
15567
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
15254
- const renderCtx = buildRenderContext(
15255
- contact,
15256
- run,
15257
- send.vars ?? {},
15258
- ctx
15259
- );
15260
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15568
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
15569
+ await ctx.collections.sends.updateOne(
15570
+ { _id: send._id },
15571
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
15572
+ );
15573
+ return;
15574
+ }
15575
+ let renderCtx;
15576
+ let rendered;
15577
+ try {
15578
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
15579
+ reason: "send",
15580
+ templateSlug: template.slug,
15581
+ flowSlug: run?.flowSlug,
15582
+ eventName: run?.triggerEvent?.name,
15583
+ eventProperties: run?.triggerEvent?.properties
15584
+ });
15585
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
15586
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15587
+ } catch (err) {
15588
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
15589
+ throw err;
15590
+ }
15261
15591
  const tracking = applyTracking(rendered.html, {
15262
15592
  sendId: String(send._id),
15263
15593
  publicUrl: ctx.config.publicUrl,
@@ -15309,13 +15639,13 @@ async function dispatchSend(sendId, ctx) {
15309
15639
  }
15310
15640
  }
15311
15641
  );
15312
- await recordHealthCounter(ctx, "sent");
15642
+ await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
15313
15643
  } catch (err) {
15314
15644
  await ctx.collections.sends.updateOne(
15315
15645
  { _id: send._id },
15316
15646
  { $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
15317
15647
  );
15318
- await recordHealthCounter(ctx, "failedToSend");
15648
+ await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
15319
15649
  if (ctx.config.onSendFailure) {
15320
15650
  try {
15321
15651
  await ctx.config.onSendFailure({ send, error: err });
@@ -15333,7 +15663,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
15333
15663
  }
15334
15664
  return ctx.config.defaultProvider;
15335
15665
  }
15336
- function buildRenderContext(contact, run, vars, ctx) {
15666
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
15337
15667
  const scope = "marketing";
15338
15668
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
15339
15669
  const token = signUnsubscribeToken(
@@ -15342,8 +15672,10 @@ function buildRenderContext(contact, run, vars, ctx) {
15342
15672
  );
15343
15673
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
15344
15674
  return {
15675
+ ...resolved,
15345
15676
  contact,
15346
15677
  vars,
15678
+ event: run?.triggerEvent?.properties ?? {},
15347
15679
  unsubscribeUrl,
15348
15680
  senderAddress: ctx.config.senderAddress
15349
15681
  };
@@ -15428,8 +15760,15 @@ async function processOneRunStep(runId, ctx) {
15428
15760
  return handleCondition(run, step, contact, ctx);
15429
15761
  case "branch":
15430
15762
  return handleBranch(run, step, contact, ctx);
15431
- case "send":
15763
+ case "send": {
15764
+ if (step.delivery) {
15765
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
15766
+ if (deliverAt.getTime() > Date.now() + 3e4) {
15767
+ return deferSendForWindow(run, deliverAt, ctx);
15768
+ }
15769
+ }
15432
15770
  return handleSend(run, step, contact, flow, ctx);
15771
+ }
15433
15772
  case "tag":
15434
15773
  return handleTag(run, step, ctx);
15435
15774
  case "fire_event":
@@ -15580,6 +15919,34 @@ async function handleWebhookStep(run, step, ctx) {
15580
15919
  }
15581
15920
  }
15582
15921
  }
15922
+ async function deferSendForWindow(run, deliverAt, ctx) {
15923
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
15924
+ // Only write once per deferral — if nextActionAt already points at (or
15925
+ // past) the slot, another worker/tick got here first.
15926
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
15927
+ {
15928
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
15929
+ $push: {
15930
+ history: {
15931
+ stepIndex: run.currentStepIndex,
15932
+ action: "send_deferred",
15933
+ at: /* @__PURE__ */ new Date(),
15934
+ details: { until: deliverAt }
15935
+ }
15936
+ }
15937
+ },
15938
+ { returnDocument: "after" }
15939
+ );
15940
+ if (!updated) return;
15941
+ await ctx.queues.advance.add(
15942
+ "advance",
15943
+ { flowRunId: String(run._id) },
15944
+ {
15945
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
15946
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
15947
+ }
15948
+ );
15949
+ }
15583
15950
  async function advanceStep(run, ctx, log, opts = {}) {
15584
15951
  const stepInc = opts.stepInc ?? 1;
15585
15952
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -15899,7 +16266,15 @@ async function promoteSoftBounces(ctx) {
15899
16266
  const cutoff = new Date(Date.now() - windowDays * 864e5);
15900
16267
  const offenders = await ctx.collections.sends.aggregate([
15901
16268
  { $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
15902
- { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
16269
+ { $sort: { queuedAt: 1 } },
16270
+ {
16271
+ $group: {
16272
+ _id: "$emailAtSend",
16273
+ count: { $sum: 1 },
16274
+ lastFromEmail: { $last: "$fromEmail" },
16275
+ lastKind: { $last: "$kind" }
16276
+ }
16277
+ },
15903
16278
  { $match: { count: { $gte: threshold } } },
15904
16279
  { $limit: 200 }
15905
16280
  ]).toArray();
@@ -15931,32 +16306,649 @@ async function promoteSoftBounces(ctx) {
15931
16306
  { emailAtSubscribe: email },
15932
16307
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
15933
16308
  );
15934
- await recordHealthCounter(ctx, "hardBounced");
16309
+ await recordHealthCounter(
16310
+ ctx,
16311
+ "hardBounced",
16312
+ o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
16313
+ );
16314
+ }
16315
+ }
16316
+ var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
16317
+ var DNS_CONCURRENCY = 8;
16318
+ var defaultResolver = {
16319
+ resolve4: (hostname) => dns.resolve4(hostname)
16320
+ };
16321
+ async function runDnsblChecks(ctx, opts = {}) {
16322
+ const cfg = ctx.config.dnsbl ?? {};
16323
+ const intervalHours = cfg.intervalHours ?? 24;
16324
+ if (!opts.force && intervalHours <= 0) {
16325
+ return { ran: false, reason: "disabled" };
16326
+ }
16327
+ const targets = collectTargets(ctx, cfg);
16328
+ if (targets.domains.length === 0 && targets.ips.length === 0) {
16329
+ return { ran: false, reason: "no_targets" };
16330
+ }
16331
+ const resolver = opts.resolver ?? defaultResolver;
16332
+ const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
16333
+ const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
16334
+ const pairs = [];
16335
+ for (const d of targets.domains) {
16336
+ for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
16337
+ }
16338
+ for (const ip of targets.ips) {
16339
+ for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
16340
+ }
16341
+ const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
16342
+ let duePairs = pairs;
16343
+ if (throttleCutoff != null) {
16344
+ const existing = await ctx.collections.dnsblChecks.find(
16345
+ {
16346
+ $or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
16347
+ },
16348
+ { projection: { target: 1, list: 1, runAt: 1 } }
16349
+ ).toArray();
16350
+ const fresh = /* @__PURE__ */ new Set();
16351
+ for (const e of existing) {
16352
+ if (new Date(e.runAt).getTime() > throttleCutoff) {
16353
+ fresh.add(`${e.target}|${e.list}`);
16354
+ }
16355
+ }
16356
+ duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
16357
+ if (duePairs.length === 0) {
16358
+ return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
16359
+ }
16360
+ }
16361
+ let listedCount = 0;
16362
+ async function processPair(p) {
16363
+ const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
16364
+ const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
16365
+ if (lookup.transient) return;
16366
+ if (lookup.result === "listed") listedCount++;
16367
+ await ctx.collections.dnsblChecks.updateOne(
16368
+ { target: p.target, list: p.list.host },
16369
+ {
16370
+ $set: {
16371
+ target: p.target,
16372
+ targetKind: p.targetKind,
16373
+ list: p.list.host,
16374
+ listLabel: p.list.label,
16375
+ result: lookup.result,
16376
+ returnCodes: lookup.returnCodes,
16377
+ errorMessage: lookup.errorMessage,
16378
+ runAt: /* @__PURE__ */ new Date()
16379
+ }
16380
+ },
16381
+ { upsert: true }
16382
+ );
16383
+ }
16384
+ let idx = 0;
16385
+ await Promise.all(
16386
+ Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
16387
+ while (idx < duePairs.length) {
16388
+ const my = idx++;
16389
+ await processPair(duePairs[my]);
16390
+ }
16391
+ })
16392
+ );
16393
+ return { ran: true, totalChecks: duePairs.length, listedCount };
16394
+ }
16395
+ function collectTargets(ctx, cfg) {
16396
+ const domains = /* @__PURE__ */ new Set();
16397
+ const registry = ctx.config.senderDomains;
16398
+ if (registry) {
16399
+ for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
16400
+ }
16401
+ const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
16402
+ if (fromDomain) domains.add(fromDomain);
16403
+ const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
16404
+ if (txnDomain) domains.add(txnDomain);
16405
+ return {
16406
+ domains: Array.from(domains),
16407
+ ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
16408
+ };
16409
+ }
16410
+ function buildQueryName(target, kind, listHost) {
16411
+ if (kind === "ip") {
16412
+ const v = net.isIP(target);
16413
+ if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
16414
+ if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
16415
+ return null;
16416
+ }
16417
+ const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
16418
+ return `${domain}.${listHost}`;
16419
+ }
16420
+ var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
16421
+ async function queryDnsbl(resolver, query) {
16422
+ try {
16423
+ const records = await resolver.resolve4(query);
16424
+ return interpretRecords(records);
16425
+ } catch (err) {
16426
+ const code = err?.code;
16427
+ if (code === "ENOTFOUND" || code === "ENODATA") {
16428
+ return { result: "clean", returnCodes: [], errorMessage: null };
16429
+ }
16430
+ if (code && TRANSIENT_DNS_CODES.has(code)) {
16431
+ return {
16432
+ transient: true,
16433
+ result: "error",
16434
+ returnCodes: [],
16435
+ errorMessage: String(err?.message ?? err)
16436
+ };
16437
+ }
16438
+ return {
16439
+ result: "error",
16440
+ returnCodes: [],
16441
+ errorMessage: String(err?.message ?? err)
16442
+ };
16443
+ }
16444
+ }
16445
+ function interpretRecords(records) {
16446
+ if (!records || records.length === 0) {
16447
+ return { result: "clean", returnCodes: [], errorMessage: null };
16448
+ }
16449
+ const errorish = records.filter((r) => r.startsWith("127.255.255."));
16450
+ if (errorish.length === records.length) {
16451
+ return {
16452
+ result: "error",
16453
+ returnCodes: records,
16454
+ errorMessage: `list returned reserved code(s): ${records.join(", ")}`
16455
+ };
15935
16456
  }
16457
+ const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
16458
+ if (listed.length > 0) {
16459
+ return { result: "listed", returnCodes: records, errorMessage: null };
16460
+ }
16461
+ return { result: "clean", returnCodes: records, errorMessage: null };
16462
+ }
16463
+ function reverseIPv4(ip) {
16464
+ return ip.split(".").reverse().join(".");
16465
+ }
16466
+ function reverseIPv6Nibbles(ip) {
16467
+ const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
16468
+ let normalized = ip;
16469
+ if (v4Match) {
16470
+ const octets = v4Match[1].split(".").map((o) => Number(o));
16471
+ if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
16472
+ const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
16473
+ const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
16474
+ normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
16475
+ }
16476
+ }
16477
+ const sides = normalized.split("::");
16478
+ let groups;
16479
+ if (sides.length === 1) {
16480
+ groups = sides[0].split(":");
16481
+ } else {
16482
+ const left = sides[0].split(":").filter(Boolean);
16483
+ const right = sides[1].split(":").filter(Boolean);
16484
+ const missing = 8 - left.length - right.length;
16485
+ groups = [...left, ...Array(missing).fill("0"), ...right];
16486
+ }
16487
+ if (groups.length !== 8) {
16488
+ throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
16489
+ }
16490
+ const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
16491
+ return padded.split("").reverse().join(".");
16492
+ }
16493
+ function registrableDomain(domain) {
16494
+ const parsed = psl.parse(domain);
16495
+ if ("domain" in parsed && parsed.domain) return parsed.domain;
16496
+ return domain;
15936
16497
  }
15937
16498
 
15938
- // src/server/runner/tick.ts
15939
- var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
15940
- async function runTick(ctx) {
16499
+ // src/server/runner/postmaster.ts
16500
+ var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
16501
+ var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
16502
+ var FETCH_TIMEOUT_MS = 15e3;
16503
+ async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
16504
+ const ctl = new AbortController();
16505
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
16506
+ try {
16507
+ return await fetcher(url, { ...init, signal: ctl.signal });
16508
+ } finally {
16509
+ clearTimeout(t);
16510
+ }
16511
+ }
16512
+ function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
16513
+ let cached = null;
16514
+ async function getAccessToken() {
16515
+ if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
16516
+ return cached.accessToken;
16517
+ }
16518
+ const body = new URLSearchParams({
16519
+ client_id: cfg.clientId,
16520
+ client_secret: cfg.clientSecret,
16521
+ refresh_token: cfg.refreshToken,
16522
+ grant_type: "refresh_token"
16523
+ });
16524
+ const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
16525
+ method: "POST",
16526
+ headers: { "content-type": "application/x-www-form-urlencoded" },
16527
+ body: body.toString()
16528
+ });
16529
+ if (!res.ok) {
16530
+ throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
16531
+ }
16532
+ const data = await res.json();
16533
+ cached = {
16534
+ accessToken: data.access_token,
16535
+ expiresAt: Date.now() + data.expires_in * 1e3
16536
+ };
16537
+ return cached.accessToken;
16538
+ }
16539
+ async function authedGet(path) {
16540
+ const token = await getAccessToken();
16541
+ const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path}`, {
16542
+ method: "GET",
16543
+ headers: { authorization: `Bearer ${token}` }
16544
+ });
16545
+ if (!res.ok) {
16546
+ throw new Error(`Postmaster GET ${path} failed: ${res.status} ${await safeText(res)}`);
16547
+ }
16548
+ return await res.json();
16549
+ }
16550
+ return {
16551
+ async listDomains() {
16552
+ const data = await authedGet("/domains");
16553
+ return data.domains ?? [];
16554
+ },
16555
+ async getLatestTrafficStats(domain) {
16556
+ const data = await authedGet(
16557
+ `/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
16558
+ );
16559
+ return data.trafficStats?.[0] ?? null;
16560
+ }
16561
+ };
16562
+ }
16563
+ async function safeText(res) {
16564
+ try {
16565
+ return await res.text();
16566
+ } catch {
16567
+ return "<no body>";
16568
+ }
16569
+ }
16570
+ async function runPostmasterPull(ctx, opts = {}) {
16571
+ const cfg = ctx.config.postmaster;
16572
+ if (!cfg) return { ran: false, reason: "not_configured" };
16573
+ const intervalHours = cfg.intervalHours ?? 24;
16574
+ if (!opts.force && intervalHours <= 0) {
16575
+ return { ran: false, reason: "disabled" };
16576
+ }
16577
+ const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
16578
+ const allDomains = resolveDomains(ctx, cfg);
16579
+ if (allDomains.length === 0) {
16580
+ return { ran: false, reason: "no_domains" };
16581
+ }
16582
+ let domains = allDomains;
16583
+ if (!opts.force) {
16584
+ const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
16585
+ const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
16586
+ const lastByDomain = /* @__PURE__ */ new Map();
16587
+ for (const s of latest) {
16588
+ const ts = new Date(s.fetchedAt).getTime();
16589
+ const cur = lastByDomain.get(s.domain) ?? 0;
16590
+ if (ts > cur) lastByDomain.set(s.domain, ts);
16591
+ }
16592
+ domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
16593
+ if (domains.length === 0) {
16594
+ return { ran: false, reason: "not_due" };
16595
+ }
16596
+ }
16597
+ const fetches = await Promise.all(
16598
+ domains.map(async (domain) => {
16599
+ try {
16600
+ const stat = await client.getLatestTrafficStats(domain);
16601
+ return { domain, stat, error: null };
16602
+ } catch (err) {
16603
+ console.error(`mailery: postmaster fetch failed for ${domain}`, err);
16604
+ return { domain, stat: null, error: err };
16605
+ }
16606
+ })
16607
+ );
16608
+ let fetched = 0;
16609
+ const trippedDomains = [];
16610
+ for (const { domain, stat } of fetches) {
16611
+ if (!stat) continue;
16612
+ const snapshot = toSnapshot(domain, stat);
16613
+ if (!snapshot) continue;
16614
+ fetched++;
16615
+ await ctx.collections.postmasterSnapshots.updateOne(
16616
+ { domain: snapshot.domain, date: snapshot.date },
16617
+ { $set: snapshot },
16618
+ { upsert: true }
16619
+ );
16620
+ if (snapshot.domainReputation === "BAD") {
16621
+ const kindsToTrip = kindsForDomain(ctx, domain);
16622
+ for (const kind of kindsToTrip) {
16623
+ const tripped = await tripBucket(ctx, domain, kind, snapshot);
16624
+ if (tripped) trippedDomains.push(`${domain}|${kind}`);
16625
+ }
16626
+ }
16627
+ }
16628
+ return { ran: true, fetched, trippedDomains };
16629
+ }
16630
+ function kindsForDomain(ctx, domain) {
16631
+ const registry = ctx.config.senderDomains ?? {};
16632
+ const entry = registry[domain.toLowerCase()];
16633
+ if (!entry) return [];
16634
+ if (entry.kind === "both") return ["marketing", "transactional"];
16635
+ return [entry.kind];
16636
+ }
16637
+ function resolveDomains(ctx, cfg) {
16638
+ if (cfg.domains && cfg.domains.length > 0) {
16639
+ return cfg.domains.map((d) => d.toLowerCase());
16640
+ }
16641
+ const out = /* @__PURE__ */ new Set();
16642
+ const registry = ctx.config.senderDomains;
16643
+ if (registry) {
16644
+ for (const d of Object.keys(registry)) out.add(d.toLowerCase());
16645
+ }
16646
+ const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
16647
+ if (f) out.add(f);
16648
+ const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
16649
+ if (t) out.add(t);
16650
+ return Array.from(out);
16651
+ }
16652
+ function toSnapshot(domain, stat) {
16653
+ const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
16654
+ if (!m) {
16655
+ console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
16656
+ return null;
16657
+ }
16658
+ const yyyymmdd = m[1];
16659
+ const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
16660
+ return {
16661
+ domain,
16662
+ date,
16663
+ domainReputation: stat.domainReputation ?? null,
16664
+ ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
16665
+ reputation: r.reputation,
16666
+ ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
16667
+ })) : null,
16668
+ userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
16669
+ spfSuccessRatio: stat.spfSuccessRatio ?? null,
16670
+ dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
16671
+ dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
16672
+ outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
16673
+ inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
16674
+ deliveryErrors: stat.deliveryErrors ?? null,
16675
+ spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
16676
+ fetchedAt: /* @__PURE__ */ new Date()
16677
+ };
16678
+ }
16679
+ async function tripBucket(ctx, domain, kind, snapshot) {
16680
+ const id = healthBucketId(domain, kind);
16681
+ const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
16682
+ const prior = await ctx.collections.health.findOne({ _id: id });
16683
+ if (prior?.status === "tripped") {
16684
+ return false;
16685
+ }
15941
16686
  await ctx.collections.health.updateOne(
15942
- { _id: "singleton" },
16687
+ { _id: id },
15943
16688
  {
15944
- $set: { updatedAt: /* @__PURE__ */ new Date() },
16689
+ $set: {
16690
+ status: "tripped",
16691
+ trippedAt: /* @__PURE__ */ new Date(),
16692
+ trippedReason: reason,
16693
+ updatedAt: /* @__PURE__ */ new Date()
16694
+ },
15945
16695
  $setOnInsert: {
15946
- _id: "singleton",
16696
+ _id: id,
16697
+ senderDomain: domain,
16698
+ kind,
15947
16699
  windowStartedAt: /* @__PURE__ */ new Date(),
15948
16700
  windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15949
- status: "healthy",
15950
- trippedAt: null,
15951
- trippedReason: null,
15952
16701
  manuallyResumedAt: null,
15953
16702
  counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15954
16703
  rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15955
16704
  }
15956
16705
  },
15957
16706
  { upsert: true }
15958
- ).catch(() => {
15959
- });
16707
+ );
16708
+ if (ctx.config.onCircuitBreakerTrip) {
16709
+ try {
16710
+ await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
16711
+ } catch {
16712
+ }
16713
+ }
16714
+ return true;
16715
+ }
16716
+
16717
+ // src/server/runner/snds.ts
16718
+ var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
16719
+ var FETCH_TIMEOUT_MS2 = 3e4;
16720
+ async function runSndsPull(ctx, opts = {}) {
16721
+ const cfg = ctx.config.snds;
16722
+ if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
16723
+ const intervalHours = cfg.intervalHours ?? 24;
16724
+ if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
16725
+ if (!opts.force) {
16726
+ const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
16727
+ if (latest[0]) {
16728
+ const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
16729
+ if (ageMs < intervalHours * 60 * 60 * 1e3) {
16730
+ return { ran: false, reason: "not_due" };
16731
+ }
16732
+ }
16733
+ }
16734
+ const fetcher = opts.fetcher ?? globalThis.fetch;
16735
+ const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
16736
+ const ctl = new AbortController();
16737
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
16738
+ let res;
16739
+ try {
16740
+ res = await fetcher(url, { method: "GET", signal: ctl.signal });
16741
+ } catch (err) {
16742
+ throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
16743
+ } finally {
16744
+ clearTimeout(t);
16745
+ }
16746
+ if (!res.ok) {
16747
+ throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
16748
+ }
16749
+ const csv = await res.text();
16750
+ const rows = parseSndsCsv(csv);
16751
+ const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
16752
+ const now = /* @__PURE__ */ new Date();
16753
+ const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
16754
+ updateOne: {
16755
+ filter: { ip: row.ip, activityStart: row.activityStart },
16756
+ update: { $set: { ...row, fetchedAt: now } },
16757
+ upsert: true
16758
+ }
16759
+ }));
16760
+ let persisted = 0;
16761
+ if (ops.length > 0) {
16762
+ const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
16763
+ persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
16764
+ }
16765
+ return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
16766
+ }
16767
+ function redactKey(s, key) {
16768
+ if (!key) return s;
16769
+ return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
16770
+ }
16771
+ async function safeText2(res) {
16772
+ try {
16773
+ return await res.text();
16774
+ } catch {
16775
+ return "<no body>";
16776
+ }
16777
+ }
16778
+ function parseSndsCsv(csv) {
16779
+ const out = [];
16780
+ for (const raw of csv.split(/\r?\n/)) {
16781
+ if (!raw.trim()) continue;
16782
+ const fields = splitCsvRow(raw);
16783
+ if (fields.length < 7) continue;
16784
+ const parsed = parseRow(fields);
16785
+ if (parsed) out.push(parsed);
16786
+ }
16787
+ return out;
16788
+ }
16789
+ function splitCsvRow(row) {
16790
+ const out = [];
16791
+ let cur = "";
16792
+ let inQuotes = false;
16793
+ for (let i = 0; i < row.length; i++) {
16794
+ const ch = row[i];
16795
+ if (inQuotes) {
16796
+ if (ch === '"') {
16797
+ if (row[i + 1] === '"') {
16798
+ cur += '"';
16799
+ i++;
16800
+ } else {
16801
+ inQuotes = false;
16802
+ }
16803
+ } else {
16804
+ cur += ch;
16805
+ }
16806
+ } else if (ch === '"') {
16807
+ inQuotes = true;
16808
+ } else if (ch === ",") {
16809
+ out.push(cur);
16810
+ cur = "";
16811
+ } else {
16812
+ cur += ch;
16813
+ }
16814
+ }
16815
+ out.push(cur);
16816
+ return out.map((f) => f.trim());
16817
+ }
16818
+ function parseRow(fields) {
16819
+ const [
16820
+ ip,
16821
+ activityStart,
16822
+ activityEnd,
16823
+ rcptCommands,
16824
+ dataCommands,
16825
+ messageRecipients,
16826
+ filterResult,
16827
+ complaintRate,
16828
+ trapMessageCount,
16829
+ sampleHelo,
16830
+ sampleMailFrom
16831
+ ] = fields;
16832
+ const start = parseSndsDate(activityStart ?? "");
16833
+ const end = parseSndsDate(activityEnd ?? "");
16834
+ if (!ip || !start || !end) return null;
16835
+ return {
16836
+ ip,
16837
+ activityStart: start,
16838
+ activityEnd: end,
16839
+ rcptCommands: toInt(rcptCommands),
16840
+ dataCommands: toInt(dataCommands),
16841
+ messageRecipients: toInt(messageRecipients),
16842
+ filterResult: normalizeFilter(filterResult ?? ""),
16843
+ complaintRate: parseComplaintRate(complaintRate ?? ""),
16844
+ trapMessageCount: toInt(trapMessageCount),
16845
+ sampleHelo: sampleHelo ? sampleHelo : null,
16846
+ sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
16847
+ };
16848
+ }
16849
+ function toInt(s) {
16850
+ if (!s) return 0;
16851
+ const n = Number(s.replace(/[^\d-]/g, ""));
16852
+ return Number.isFinite(n) ? n : 0;
16853
+ }
16854
+ function normalizeFilter(s) {
16855
+ const u = s.toUpperCase().trim();
16856
+ if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
16857
+ return "UNKNOWN";
16858
+ }
16859
+ function parseComplaintRate(s) {
16860
+ const trimmed = s.trim();
16861
+ if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
16862
+ const cleaned = trimmed.replace(/%/g, "");
16863
+ let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16864
+ if (m) return Number(m[1]) / 100;
16865
+ m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16866
+ if (m) return Number(m[1]) / 100;
16867
+ m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16868
+ if (m) return Number(m[2]) / 100;
16869
+ m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
16870
+ if (m) return Number(m[1]) / 100;
16871
+ return null;
16872
+ }
16873
+ function parseSndsDate(s) {
16874
+ const trimmed = s.trim();
16875
+ if (!trimmed) return null;
16876
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
16877
+ if (m) {
16878
+ const month = Number(m[1]) - 1;
16879
+ const day = Number(m[2]);
16880
+ const year = Number(m[3]);
16881
+ let hour = Number(m[4]);
16882
+ const minute = Number(m[5]);
16883
+ const second = Number(m[6] ?? 0);
16884
+ const meridiem = m[7]?.toUpperCase();
16885
+ if (meridiem === "PM" && hour < 12) hour += 12;
16886
+ if (meridiem === "AM" && hour === 12) hour = 0;
16887
+ const wallMs = Date.UTC(year, month, day, hour, minute, second);
16888
+ if (!Number.isFinite(wallMs)) return null;
16889
+ const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
16890
+ return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
16891
+ }
16892
+ const d = new Date(trimmed);
16893
+ if (!isNaN(d.getTime())) return d;
16894
+ return null;
16895
+ }
16896
+ function isUsPacificDst(year, month0, day) {
16897
+ if (month0 > 2 && month0 < 10) return true;
16898
+ if (month0 < 2 || month0 > 10) return false;
16899
+ if (month0 === 2) {
16900
+ const dst2 = nthSundayOfMonth(year, 2, 2);
16901
+ return day >= dst2;
16902
+ }
16903
+ const dst = nthSundayOfMonth(year, 10, 1);
16904
+ return day < dst;
16905
+ }
16906
+ function nthSundayOfMonth(year, month0, n) {
16907
+ const first = new Date(Date.UTC(year, month0, 1));
16908
+ const dayOfWeek = first.getUTCDay();
16909
+ const firstSunday = 1 + (7 - dayOfWeek) % 7;
16910
+ return firstSunday + (n - 1) * 7;
16911
+ }
16912
+ var _lastPruneAt = 0;
16913
+ var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
16914
+ async function pruneDmarcFailures(ctx, opts = {}) {
16915
+ const cfg = ctx.config.dmarc;
16916
+ const days = cfg?.retentionDays ?? 90;
16917
+ if (days <= 0) return 0;
16918
+ if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
16919
+ _lastPruneAt = Date.now();
16920
+ const cutoff = new Date(Date.now() - days * 864e5);
16921
+ const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
16922
+ return r.deletedCount ?? 0;
16923
+ }
16924
+
16925
+ // src/server/runner/tick.ts
16926
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
16927
+ async function runTick(ctx) {
16928
+ try {
16929
+ await ctx.collections.health.updateOne(
16930
+ { _id: HEALTH_AGG_ID },
16931
+ {
16932
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
16933
+ $setOnInsert: {
16934
+ _id: HEALTH_AGG_ID,
16935
+ senderDomain: null,
16936
+ kind: null,
16937
+ windowStartedAt: /* @__PURE__ */ new Date(),
16938
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
16939
+ status: "healthy",
16940
+ trippedAt: null,
16941
+ trippedReason: null,
16942
+ manuallyResumedAt: null,
16943
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
16944
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
16945
+ }
16946
+ },
16947
+ { upsert: true }
16948
+ );
16949
+ } catch (err) {
16950
+ console.error("mailery: heartbeat write failed", err);
16951
+ }
15960
16952
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15961
16953
  console.error("mailery: triggers scan failed", err);
15962
16954
  });
@@ -15978,6 +16970,20 @@ async function runTick(ctx) {
15978
16970
  await promoteSoftBounces(ctx).catch((err) => {
15979
16971
  console.error("mailery: soft-bounce promotion failed", err);
15980
16972
  });
16973
+ await Promise.all([
16974
+ runDnsblChecks(ctx).catch((err) => {
16975
+ console.error("mailery: dnsbl checks failed", err);
16976
+ }),
16977
+ runPostmasterPull(ctx).catch((err) => {
16978
+ console.error("mailery: postmaster pull failed", err);
16979
+ }),
16980
+ runSndsPull(ctx).catch((err) => {
16981
+ console.error("mailery: snds pull failed", err);
16982
+ }),
16983
+ pruneDmarcFailures(ctx).catch((err) => {
16984
+ console.error("mailery: dmarc prune failed", err);
16985
+ })
16986
+ ]);
15981
16987
  }
15982
16988
  async function sweepStrandedSends(ctx) {
15983
16989
  const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
@@ -16035,6 +17041,10 @@ async function processScheduledBroadcasts2(ctx) {
16035
17041
  }
16036
17042
 
16037
17043
  // src/server/runner/webhook.ts
17044
+ function dimsFromSend(send) {
17045
+ if (!send) return null;
17046
+ return { fromEmail: send.fromEmail, kind: send.kind };
17047
+ }
16038
17048
  async function applyWebhookEvent(event, ctx) {
16039
17049
  const send = await ctx.collections.sends.findOne(
16040
17050
  event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
@@ -16048,7 +17058,7 @@ async function applyWebhookEvent(event, ctx) {
16048
17058
  { $set: { status: "delivered", deliveredAt: event.occurredAt } }
16049
17059
  );
16050
17060
  }
16051
- await recordHealthCounter(ctx, "delivered");
17061
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
16052
17062
  break;
16053
17063
  case "open":
16054
17064
  if (send) {
@@ -16103,8 +17113,11 @@ async function applyWebhookEvent(event, ctx) {
16103
17113
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
16104
17114
  );
16105
17115
  }
16106
- await recordHealthCounter(ctx, "bounced");
16107
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
17116
+ {
17117
+ const dims = dimsFromSend(send);
17118
+ await recordHealthCounter(ctx, "bounced", dims);
17119
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
17120
+ }
16108
17121
  break;
16109
17122
  }
16110
17123
  case "complaint":
@@ -16120,7 +17133,7 @@ async function applyWebhookEvent(event, ctx) {
16120
17133
  { emailAtSubscribe: event.email },
16121
17134
  { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
16122
17135
  );
16123
- await recordHealthCounter(ctx, "complained");
17136
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
16124
17137
  break;
16125
17138
  case "unsubscribe":
16126
17139
  if (send) {
@@ -16186,10 +17199,12 @@ var Mailer = class _Mailer {
16186
17199
  db: this.db,
16187
17200
  collections: this.collections,
16188
17201
  adapter: this.adapter,
17202
+ varsAdapter: this.config.varsAdapter,
16189
17203
  providers: this.providers,
16190
17204
  queues: this.queues,
16191
17205
  config: this.config,
16192
- handlebarsHelpers: this.config.handlebarsHelpers
17206
+ handlebarsHelpers: this.config.handlebarsHelpers,
17207
+ audit: (entry) => this.audit(entry)
16193
17208
  };
16194
17209
  }
16195
17210
  /**
@@ -16264,6 +17279,10 @@ var Mailer = class _Mailer {
16264
17279
  if (!config.providers[config.defaultProvider]) {
16265
17280
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
16266
17281
  }
17282
+ if (config.varsAdapter) {
17283
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
17284
+ assertNoReservedVarKeys2(config.varsAdapter);
17285
+ }
16267
17286
  const collections = getCollections(config.db, config.collectionPrefix);
16268
17287
  await ensureIndexes(config.db, config.collectionPrefix);
16269
17288
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -16479,6 +17498,72 @@ var Mailer = class _Mailer {
16479
17498
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
16480
17499
  }
16481
17500
  }
17501
+ // -------------------------------------------------------------------------
17502
+ // Flow abort
17503
+ // -------------------------------------------------------------------------
17504
+ /**
17505
+ * Abort every active run of one flow for a contact, immediately. Runs parked
17506
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
17507
+ * no-op. Also cancels any of the flow's emails still sitting in the send
17508
+ * queue for this contact (queued or awaiting retry), so an abort means no
17509
+ * further mail, not just no further steps.
17510
+ *
17511
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
17512
+ * same handler that processes the business event ("user upgraded").
17513
+ */
17514
+ async abortFlow(flowSlug, externalId, opts = {}) {
17515
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17516
+ const flow = await this.collections.flows.findOne(
17517
+ { slug: parsed.flowSlug },
17518
+ { projection: { _id: 1 } }
17519
+ );
17520
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17521
+ const result = await this.abortActiveRuns(
17522
+ { externalId: parsed.externalId, flowId: flow._id },
17523
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17524
+ );
17525
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17526
+ await this.audit({
17527
+ actor: "host",
17528
+ action: "flow.abort",
17529
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17530
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17531
+ });
17532
+ }
17533
+ return result;
17534
+ }
17535
+ /**
17536
+ * Abort every active flow run for a contact across all flows. Same semantics
17537
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
17538
+ */
17539
+ async abortAllFlows(externalId, opts = {}) {
17540
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
17541
+ const result = await this.abortActiveRuns(
17542
+ { externalId: parsed.externalId },
17543
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17544
+ );
17545
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17546
+ await this.audit({
17547
+ actor: "host",
17548
+ action: "flow.abort_all",
17549
+ resource: { collection: "mailer_flow_runs" },
17550
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17551
+ });
17552
+ }
17553
+ return result;
17554
+ }
17555
+ async abortActiveRuns(filter, exitReason) {
17556
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
17557
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
17558
+ for (const run of runs) {
17559
+ await exitFlowRun(run, exitReason, this.runnerContext);
17560
+ }
17561
+ const cancelled = await this.collections.sends.updateMany(
17562
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
17563
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
17564
+ );
17565
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
17566
+ }
16482
17567
  /**
16483
17568
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
16484
17569
  * suppression row to block re-import. INVARIANT 9.