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.cjs CHANGED
@@ -1,13 +1,16 @@
1
1
  'use strict';
2
2
 
3
+ var zod = require('zod');
3
4
  var mongodb = require('mongodb');
4
5
  var crypto2 = require('crypto');
5
6
  var sgMail = require('@sendgrid/mail');
6
- var zod = require('zod');
7
7
  var IORedis = require('ioredis');
8
8
  var Handlebars = require('handlebars');
9
9
  var htmlToText = require('html-to-text');
10
10
  var mjml2html = require('mjml');
11
+ var dns = require('dns/promises');
12
+ var net = require('net');
13
+ var psl = require('psl');
11
14
 
12
15
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
16
 
@@ -16,6 +19,9 @@ var sgMail__default = /*#__PURE__*/_interopDefault(sgMail);
16
19
  var IORedis__default = /*#__PURE__*/_interopDefault(IORedis);
17
20
  var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
18
21
  var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
22
+ var dns__default = /*#__PURE__*/_interopDefault(dns);
23
+ var net__default = /*#__PURE__*/_interopDefault(net);
24
+ var psl__default = /*#__PURE__*/_interopDefault(psl);
19
25
 
20
26
  var __create = Object.create;
21
27
  var __defProp = Object.defineProperty;
@@ -57,6 +63,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
57
63
  ));
58
64
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
59
65
 
66
+ // src/server/adapters/vars.ts
67
+ var vars_exports = {};
68
+ __export(vars_exports, {
69
+ RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
70
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
71
+ defineVars: () => defineVars,
72
+ resolveVars: () => resolveVars,
73
+ varsJsonSchema: () => varsJsonSchema
74
+ });
75
+ function defineVars(adapter) {
76
+ return adapter;
77
+ }
78
+ function assertNoReservedVarKeys(adapter) {
79
+ const json = varsJsonSchema(adapter);
80
+ const props = json && typeof json === "object" ? json.properties : void 0;
81
+ if (!props) return;
82
+ const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
83
+ if (clashes.length > 0) {
84
+ throw new Error(
85
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
86
+ );
87
+ }
88
+ }
89
+ function varsJsonSchema(adapter) {
90
+ return zod.z.toJSONSchema(adapter.schema, { io: "output" });
91
+ }
92
+ async function resolveVars(adapter, contact, info) {
93
+ if (!adapter) return {};
94
+ const resolved = await adapter.resolve(contact, info);
95
+ if (!resolved || typeof resolved !== "object") return {};
96
+ const out = { ...resolved };
97
+ for (const k of RESERVED_VAR_KEYS) delete out[k];
98
+ return out;
99
+ }
100
+ var RESERVED_VAR_KEYS;
101
+ var init_vars = __esm({
102
+ "src/server/adapters/vars.ts"() {
103
+ RESERVED_VAR_KEYS = [
104
+ "contact",
105
+ "vars",
106
+ "event",
107
+ "unsubscribeUrl",
108
+ "viewInBrowserUrl",
109
+ "preferenceCenterUrl",
110
+ "senderAddress"
111
+ ];
112
+ }
113
+ });
114
+
60
115
  // src/server/adapters/mongo.ts
61
116
  var mongo_exports = {};
62
117
  __export(mongo_exports, {
@@ -2999,19 +3054,19 @@ var require_range = __commonJS({
2999
3054
  var replaceCaret = (comp, options2) => {
3000
3055
  debug("caret", comp, options2);
3001
3056
  const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
3002
- const z2 = options2.includePrerelease ? "-0" : "";
3057
+ const z3 = options2.includePrerelease ? "-0" : "";
3003
3058
  return comp.replace(r, (_, M, m, p, pr) => {
3004
3059
  debug("caret", comp, _, M, m, p, pr);
3005
3060
  let ret;
3006
3061
  if (isX(M)) {
3007
3062
  ret = "";
3008
3063
  } else if (isX(m)) {
3009
- ret = `>=${M}.0.0${z2} <${+M + 1}.0.0-0`;
3064
+ ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
3010
3065
  } else if (isX(p)) {
3011
3066
  if (M === "0") {
3012
- ret = `>=${M}.${m}.0${z2} <${M}.${+m + 1}.0-0`;
3067
+ ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
3013
3068
  } else {
3014
- ret = `>=${M}.${m}.0${z2} <${+M + 1}.0.0-0`;
3069
+ ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
3015
3070
  }
3016
3071
  } else if (pr) {
3017
3072
  debug("replaceCaret pr", pr);
@@ -3028,9 +3083,9 @@ var require_range = __commonJS({
3028
3083
  debug("no pr");
3029
3084
  if (M === "0") {
3030
3085
  if (m === "0") {
3031
- ret = `>=${M}.${m}.${p}${z2} <${M}.${m}.${+p + 1}-0`;
3086
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
3032
3087
  } else {
3033
- ret = `>=${M}.${m}.${p}${z2} <${M}.${+m + 1}.0-0`;
3088
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
3034
3089
  }
3035
3090
  } else {
3036
3091
  ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
@@ -4165,7 +4220,7 @@ var require_getport = __commonJS({
4165
4220
  exports.tryPort = tryPort;
4166
4221
  exports.resetPortsCache = resetPortsCache;
4167
4222
  var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
4168
- var net = tslib_1.__importStar(__require("net"));
4223
+ var net2 = tslib_1.__importStar(__require("net"));
4169
4224
  var debug_1 = tslib_1.__importDefault(require_src());
4170
4225
  var log = (0, debug_1.default)("MongoMS:GetPort");
4171
4226
  exports.MIN_PORT = 1024;
@@ -4213,7 +4268,7 @@ var require_getport = __commonJS({
4213
4268
  }
4214
4269
  function tryPort(port) {
4215
4270
  return new Promise((res, rej) => {
4216
- const server = net.createServer();
4271
+ const server = net2.createServer();
4217
4272
  if (typeof server.unref === "function") {
4218
4273
  server.unref();
4219
4274
  }
@@ -9179,7 +9234,7 @@ var require_dist = __commonJS({
9179
9234
  };
9180
9235
  Object.defineProperty(exports, "__esModule", { value: true });
9181
9236
  exports.Agent = void 0;
9182
- var net = __importStar2(__require("net"));
9237
+ var net2 = __importStar2(__require("net"));
9183
9238
  var http = __importStar2(__require("http"));
9184
9239
  var https_1 = __require("https");
9185
9240
  __exportStar2(require_helpers(), exports);
@@ -9219,7 +9274,7 @@ var require_dist = __commonJS({
9219
9274
  if (!this.sockets[name]) {
9220
9275
  this.sockets[name] = [];
9221
9276
  }
9222
- const fakeSocket = new net.Socket({ writable: false });
9277
+ const fakeSocket = new net2.Socket({ writable: false });
9223
9278
  this.sockets[name].push(fakeSocket);
9224
9279
  this.totalSocketCount++;
9225
9280
  return fakeSocket;
@@ -9429,7 +9484,7 @@ var require_dist2 = __commonJS({
9429
9484
  };
9430
9485
  Object.defineProperty(exports, "__esModule", { value: true });
9431
9486
  exports.HttpsProxyAgent = void 0;
9432
- var net = __importStar2(__require("net"));
9487
+ var net2 = __importStar2(__require("net"));
9433
9488
  var tls = __importStar2(__require("tls"));
9434
9489
  var assert_1 = __importDefault2(__require("assert"));
9435
9490
  var debug_1 = __importDefault2(require_src());
@@ -9438,7 +9493,7 @@ var require_dist2 = __commonJS({
9438
9493
  var parse_proxy_response_1 = require_parse_proxy_response();
9439
9494
  var debug = (0, debug_1.default)("https-proxy-agent");
9440
9495
  var setServernameFromNonIpHost = (options2) => {
9441
- if (options2.servername === void 0 && options2.host && !net.isIP(options2.host)) {
9496
+ if (options2.servername === void 0 && options2.host && !net2.isIP(options2.host)) {
9442
9497
  return {
9443
9498
  ...options2,
9444
9499
  servername: options2.host
@@ -9478,10 +9533,10 @@ var require_dist2 = __commonJS({
9478
9533
  socket = tls.connect(setServernameFromNonIpHost(this.connectOpts));
9479
9534
  } else {
9480
9535
  debug("Creating `net.Socket`: %o", this.connectOpts);
9481
- socket = net.connect(this.connectOpts);
9536
+ socket = net2.connect(this.connectOpts);
9482
9537
  }
9483
9538
  const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders };
9484
- const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
9539
+ const host = net2.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
9485
9540
  let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r
9486
9541
  `;
9487
9542
  if (proxy.username || proxy.password) {
@@ -9514,7 +9569,7 @@ var require_dist2 = __commonJS({
9514
9569
  return socket;
9515
9570
  }
9516
9571
  socket.destroy();
9517
- const fakeSocket = new net.Socket({ writable: false });
9572
+ const fakeSocket = new net2.Socket({ writable: false });
9518
9573
  fakeSocket.readable = true;
9519
9574
  req.once("socket", (s) => {
9520
9575
  debug("Replaying proxy buffer for failed request");
@@ -14150,6 +14205,12 @@ var tagInputSchema = zod.z.object({
14150
14205
  externalId: externalIdSchema,
14151
14206
  tag: zod.z.string().min(1).max(128)
14152
14207
  });
14208
+ var abortFlowInputSchema = zod.z.object({
14209
+ flowSlug: slugSchema,
14210
+ externalId: externalIdSchema,
14211
+ reason: zod.z.string().min(1).max(200).optional()
14212
+ });
14213
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14153
14214
  var sendOneOffInputSchema = zod.z.object({
14154
14215
  templateSlug: slugSchema,
14155
14216
  externalId: externalIdSchema,
@@ -14179,7 +14240,13 @@ var flowStepSchema = zod.z.lazy(
14179
14240
  type: zod.z.literal("send"),
14180
14241
  templateSlug: slugSchema,
14181
14242
  providerOverride: zod.z.string().optional(),
14182
- vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
14243
+ vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
14244
+ delivery: zod.z.object({
14245
+ weekdaysOnly: zod.z.boolean().optional(),
14246
+ timeOfDay: zod.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
14247
+ useContactTimezone: zod.z.boolean().optional(),
14248
+ timezone: zod.z.string().optional()
14249
+ }).optional()
14183
14250
  }),
14184
14251
  zod.z.object({
14185
14252
  type: zod.z.literal("tag"),
@@ -14263,6 +14330,17 @@ var predicateSchema = zod.z.lazy(
14263
14330
  );
14264
14331
 
14265
14332
  // src/server/config.ts
14333
+ var DEFAULT_DOMAIN_DNSBL_LISTS = [
14334
+ { host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
14335
+ { host: "multi.surbl.org", label: "SURBL" },
14336
+ { host: "multi.uribl.com", label: "URIBL" }
14337
+ ];
14338
+ var DEFAULT_IP_DNSBL_LISTS = [
14339
+ { host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
14340
+ { host: "b.barracudacentral.org", label: "Barracuda" },
14341
+ { host: "dnsbl.sorbs.net", label: "SORBS" },
14342
+ { host: "bl.spamcop.net", label: "SpamCop" }
14343
+ ];
14266
14344
  var DEFAULTS = {
14267
14345
  collectionPrefix: "mailer_",
14268
14346
  requireDoubleOptIn: false,
@@ -14323,6 +14401,10 @@ function resolveConfig(c) {
14323
14401
  }
14324
14402
 
14325
14403
  // src/server/models/index.ts
14404
+ var HEALTH_AGG_ID = "agg";
14405
+ function healthBucketId(senderDomain, kind) {
14406
+ return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
14407
+ }
14326
14408
  function getCollections(db, prefix = "mailer_") {
14327
14409
  return {
14328
14410
  subscriptions: db.collection(`${prefix}subscriptions`),
@@ -14340,7 +14422,14 @@ function getCollections(db, prefix = "mailer_") {
14340
14422
  auditLog: db.collection(`${prefix}audit_log`),
14341
14423
  webhookEvents: db.collection(`${prefix}webhook_events`),
14342
14424
  health: db.collection(`${prefix}health`),
14343
- contactTags: db.collection(`${prefix}contact_tags`)
14425
+ contactTags: db.collection(`${prefix}contact_tags`),
14426
+ dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
14427
+ postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
14428
+ sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
14429
+ dmarcReports: db.collection(`${prefix}dmarc_reports`),
14430
+ dmarcFailures: db.collection(`${prefix}dmarc_failures`),
14431
+ dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
14432
+ mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
14344
14433
  };
14345
14434
  }
14346
14435
  async function ensureIndexes(db, prefix = "mailer_") {
@@ -14412,8 +14501,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
14412
14501
  c.contactTags.createIndexes([
14413
14502
  { key: { externalId: 1, tag: 1 }, unique: true },
14414
14503
  { key: { tag: 1 } }
14504
+ ]),
14505
+ c.health.createIndexes([
14506
+ { key: { senderDomain: 1, kind: 1 } },
14507
+ { key: { status: 1 } },
14508
+ // setup-status reads the most-recently-touched health doc as a heartbeat.
14509
+ { key: { updatedAt: -1 } }
14510
+ ]),
14511
+ c.dnsblChecks.createIndexes([
14512
+ { key: { target: 1, list: 1 }, unique: true },
14513
+ // Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
14514
+ { key: { result: 1, target: 1, list: 1 } },
14515
+ // TTL — stale rows for targets the operator removed disappear after
14516
+ // 60 days without manual cleanup. The puller refreshes runAt on
14517
+ // every active target so existing targets stay indefinitely.
14518
+ { key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
14519
+ ]),
14520
+ c.postmasterSnapshots.createIndexes([
14521
+ { key: { domain: 1, date: 1 }, unique: true },
14522
+ { key: { domain: 1, fetchedAt: -1 } },
14523
+ { key: { domainReputation: 1 } }
14524
+ ]),
14525
+ c.sndsSnapshots.createIndexes([
14526
+ { key: { ip: 1, activityStart: 1 }, unique: true },
14527
+ { key: { ip: 1, fetchedAt: -1 } },
14528
+ { key: { filterResult: 1 } }
14529
+ ]),
14530
+ c.dmarcReports.createIndexes([
14531
+ { key: { reportId: 1, orgName: 1 }, unique: true },
14532
+ { key: { domain: 1, rangeEnd: -1 } },
14533
+ // Cross-domain "most recent reports" queries scan a lot without this.
14534
+ { key: { rangeEnd: -1 } },
14535
+ { key: { receivedAt: -1 } }
14536
+ ]),
14537
+ c.dmarcFailures.createIndexes([
14538
+ { key: { reportId: 1, sourceIp: 1 }, unique: true },
14539
+ { key: { domain: 1, day: -1 } },
14540
+ { key: { sourceIp: 1, day: -1 } },
14541
+ { key: { receivedAt: 1 } }
14542
+ // for retention pruning
14543
+ ]),
14544
+ c.dmarcSourceTags.createIndexes([
14545
+ { key: { ip: 1 }, unique: true }
14546
+ ]),
14547
+ c.mailTesterScores.createIndexes([
14548
+ { key: { contentKey: 1 }, unique: true },
14549
+ { key: { templateSlug: 1, fetchedAt: -1 } },
14550
+ // TTL — Mongo auto-deletes expired scores so we never serve stale data.
14551
+ { key: { expiresAt: 1 }, expireAfterSeconds: 0 }
14415
14552
  ])
14416
14553
  ]);
14554
+ await c.health.deleteOne({ _id: "singleton" }).catch(() => {
14555
+ });
14417
14556
  }
14418
14557
  var EventRegistry = class {
14419
14558
  policies = /* @__PURE__ */ new Map();
@@ -14426,6 +14565,9 @@ var EventRegistry = class {
14426
14565
  policy(name) {
14427
14566
  return this.policies.get(name);
14428
14567
  }
14568
+ list() {
14569
+ return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
14570
+ }
14429
14571
  /**
14430
14572
  * Derive a dedupeKey for an event call. Returns null when no policy is
14431
14573
  * registered AND no key was passed — caller should throw.
@@ -14839,6 +14981,7 @@ async function tryEnterFlow(flow, event, ctx) {
14839
14981
  flowSlug: flow.slug,
14840
14982
  flowVersion: flow.version,
14841
14983
  emailAtEntry: sub.emailAtSubscribe,
14984
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14842
14985
  enteredAt: /* @__PURE__ */ new Date(),
14843
14986
  status: "active",
14844
14987
  currentStepIndex: 0,
@@ -14936,6 +15079,105 @@ function effectiveLowerBound(ctx, opts) {
14936
15079
  }
14937
15080
  return null;
14938
15081
  }
15082
+
15083
+ // src/server/runner/delivery-window.ts
15084
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
15085
+ function computeDeliveryTime(now, window2, contactTimezone) {
15086
+ const tz = pickTimezone(window2, contactTimezone);
15087
+ let candidate = now;
15088
+ if (window2.timeOfDay) {
15089
+ const [hh, mm] = window2.timeOfDay.split(":").map(Number);
15090
+ const local = localParts(candidate, tz);
15091
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
15092
+ if (candidate.getTime() < todaySlot.getTime()) {
15093
+ candidate = todaySlot;
15094
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
15095
+ const next = addLocalDays(local, 1);
15096
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
15097
+ }
15098
+ }
15099
+ if (window2.weekdaysOnly) {
15100
+ for (let guard = 0; guard < 3; guard++) {
15101
+ const local = localParts(candidate, tz);
15102
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
15103
+ const shift = local.weekday === "Sat" ? 2 : 1;
15104
+ const moved = addLocalDays(local, shift);
15105
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
15106
+ }
15107
+ }
15108
+ return candidate;
15109
+ }
15110
+ function pickTimezone(window2, contactTimezone) {
15111
+ const candidates = [
15112
+ window2.useContactTimezone ? contactTimezone : void 0,
15113
+ window2.timezone,
15114
+ "UTC"
15115
+ ];
15116
+ for (const tz of candidates) {
15117
+ if (tz && isValidTimezone(tz)) return tz;
15118
+ }
15119
+ return "UTC";
15120
+ }
15121
+ var validatedZones = /* @__PURE__ */ new Map();
15122
+ function isValidTimezone(tz) {
15123
+ const cached = validatedZones.get(tz);
15124
+ if (cached !== void 0) return cached;
15125
+ let ok = true;
15126
+ try {
15127
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
15128
+ } catch {
15129
+ ok = false;
15130
+ }
15131
+ validatedZones.set(tz, ok);
15132
+ return ok;
15133
+ }
15134
+ var partFormatters = /* @__PURE__ */ new Map();
15135
+ function formatterFor(tz) {
15136
+ let f = partFormatters.get(tz);
15137
+ if (!f) {
15138
+ f = new Intl.DateTimeFormat("en-US", {
15139
+ timeZone: tz,
15140
+ year: "numeric",
15141
+ month: "2-digit",
15142
+ day: "2-digit",
15143
+ hour: "2-digit",
15144
+ minute: "2-digit",
15145
+ second: "2-digit",
15146
+ weekday: "short",
15147
+ hour12: false
15148
+ });
15149
+ partFormatters.set(tz, f);
15150
+ }
15151
+ return f;
15152
+ }
15153
+ function localParts(date, tz) {
15154
+ const parts = {};
15155
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
15156
+ return {
15157
+ y: Number(parts.year),
15158
+ mo: Number(parts.month),
15159
+ d: Number(parts.day),
15160
+ hh: Number(parts.hour) % 24,
15161
+ // Intl emits '24' for midnight in some locales
15162
+ mi: Number(parts.minute),
15163
+ ss: Number(parts.second),
15164
+ weekday: parts.weekday
15165
+ };
15166
+ }
15167
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
15168
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
15169
+ for (let i = 0; i < 2; i++) {
15170
+ const p = localParts(new Date(ts), tz);
15171
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
15172
+ const offset = asUtc - ts;
15173
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
15174
+ }
15175
+ return new Date(ts);
15176
+ }
15177
+ function addLocalDays(p, days) {
15178
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
15179
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
15180
+ }
14939
15181
  async function compileTemplate(mjml) {
14940
15182
  const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
14941
15183
  const plainText = derivePlaintext(out.html);
@@ -15053,6 +15295,9 @@ function makeHandlebars(extra) {
15053
15295
  return hb;
15054
15296
  }
15055
15297
 
15298
+ // src/server/runner/send.ts
15299
+ init_vars();
15300
+
15056
15301
  // src/server/runner/suppression.ts
15057
15302
  var SCOPES_BY_KIND = {
15058
15303
  marketing: ["all", "marketing"],
@@ -15075,21 +15320,59 @@ async function isSuppressed(collections, email, kind) {
15075
15320
  return { suppressed: false };
15076
15321
  }
15077
15322
 
15323
+ // src/server/templates/sender-domain.ts
15324
+ function extractDomain(email) {
15325
+ if (typeof email !== "string") return null;
15326
+ const at = email.lastIndexOf("@");
15327
+ if (at <= 0 || at === email.length - 1) return null;
15328
+ return email.slice(at + 1).toLowerCase().trim();
15329
+ }
15330
+
15078
15331
  // src/server/runner/health.ts
15079
- async function recordHealthCounter(ctx, counter2, by = 1) {
15332
+ var ZERO_COUNTERS = {
15333
+ sent: 0,
15334
+ delivered: 0,
15335
+ bounced: 0,
15336
+ hardBounced: 0,
15337
+ softBounced: 0,
15338
+ complained: 0,
15339
+ failedToSend: 0
15340
+ };
15341
+ var ZERO_RATES = {
15342
+ bounceRate: 0,
15343
+ hardBounceRate: 0,
15344
+ complaintRate: 0,
15345
+ failureRate: 0
15346
+ };
15347
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15348
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
15349
+ const writes = [];
15350
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
15351
+ if (dims) {
15352
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
15353
+ if (domain) {
15354
+ const id = healthBucketId(domain, dims.kind);
15355
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
15356
+ }
15357
+ }
15358
+ await Promise.all(writes);
15359
+ }
15360
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
15080
15361
  await ctx.collections.health.updateOne(
15081
- { _id: "singleton" },
15362
+ { _id },
15082
15363
  {
15083
15364
  $inc: { [`counters.${counter2}`]: by },
15084
15365
  $setOnInsert: {
15085
- _id: "singleton",
15366
+ _id,
15367
+ senderDomain,
15368
+ kind,
15086
15369
  windowStartedAt: /* @__PURE__ */ new Date(),
15087
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15370
+ windowDurationMs: windowMs,
15088
15371
  status: "healthy",
15089
15372
  trippedAt: null,
15090
15373
  trippedReason: null,
15091
15374
  manuallyResumedAt: null,
15092
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15375
+ rates: { ...ZERO_RATES }
15093
15376
  },
15094
15377
  $set: { updatedAt: /* @__PURE__ */ new Date() }
15095
15378
  },
@@ -15099,73 +15382,107 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
15099
15382
  async function evaluateHealth(ctx) {
15100
15383
  const cb = ctx.config.circuitBreaker;
15101
15384
  const windowMs = cb.windowMinutes * 60 * 1e3;
15102
- const doc = await ctx.collections.health.findOne({ _id: "singleton" });
15103
- if (!doc) return;
15104
- const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15105
- if (windowAge > windowMs && doc.status !== "tripped") {
15106
- await ctx.collections.health.updateOne(
15107
- { _id: "singleton" },
15108
- {
15109
- $set: {
15110
- windowStartedAt: /* @__PURE__ */ new Date(),
15111
- windowDurationMs: windowMs,
15112
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15113
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 },
15114
- updatedAt: /* @__PURE__ */ new Date()
15115
- }
15116
- }
15117
- );
15118
- return;
15385
+ const docs = await ctx.collections.health.find({}).toArray();
15386
+ if (docs.length === 0) return;
15387
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
15388
+ if (!hasAgg) {
15389
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
15119
15390
  }
15120
- const c = doc.counters;
15121
- const total = c.sent || 1;
15122
- const rates = {
15123
- bounceRate: c.bounced / total,
15124
- hardBounceRate: c.hardBounced / total,
15125
- complaintRate: c.complained / total,
15126
- failureRate: c.failedToSend / total
15127
- };
15128
- await ctx.collections.health.updateOne(
15129
- { _id: "singleton" },
15130
- { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15131
- );
15132
- if (c.sent < cb.minSendsBeforeEval) return;
15133
- if (doc.status === "tripped") return;
15134
- let trippedReason = null;
15135
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15136
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15137
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15138
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15139
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15140
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15141
- }
15142
- if (trippedReason) {
15391
+ for (const doc of docs) {
15392
+ const isAgg = doc._id === HEALTH_AGG_ID;
15393
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
15394
+ if (windowAge > windowMs && doc.status !== "tripped") {
15395
+ await ctx.collections.health.updateOne(
15396
+ { _id: doc._id },
15397
+ {
15398
+ $set: {
15399
+ windowStartedAt: /* @__PURE__ */ new Date(),
15400
+ windowDurationMs: windowMs,
15401
+ counters: { ...ZERO_COUNTERS },
15402
+ rates: { ...ZERO_RATES },
15403
+ status: "healthy",
15404
+ updatedAt: /* @__PURE__ */ new Date()
15405
+ }
15406
+ }
15407
+ );
15408
+ continue;
15409
+ }
15410
+ const c = doc.counters;
15411
+ const total = c.sent || 1;
15412
+ const rates = {
15413
+ bounceRate: c.bounced / total,
15414
+ hardBounceRate: c.hardBounced / total,
15415
+ complaintRate: c.complained / total,
15416
+ failureRate: c.failedToSend / total
15417
+ };
15143
15418
  await ctx.collections.health.updateOne(
15144
- { _id: "singleton" },
15145
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15419
+ { _id: doc._id },
15420
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
15146
15421
  );
15147
- if (ctx.config.onCircuitBreakerTrip) {
15148
- try {
15149
- await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
15150
- } catch {
15422
+ if (isAgg) continue;
15423
+ if (c.sent < cb.minSendsBeforeEval) continue;
15424
+ if (doc.status === "tripped") continue;
15425
+ let trippedReason = null;
15426
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15427
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15428
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15429
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15430
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15431
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15432
+ }
15433
+ if (trippedReason) {
15434
+ const result = await ctx.collections.health.updateOne(
15435
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
15436
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
15437
+ );
15438
+ if (result.modifiedCount > 0) {
15439
+ if (ctx.audit) {
15440
+ try {
15441
+ await ctx.audit({
15442
+ actor: "system:circuit-breaker",
15443
+ action: "health.trip",
15444
+ resource: {
15445
+ collection: "mailer_health",
15446
+ id: String(doc._id),
15447
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
15448
+ },
15449
+ diffSummary: trippedReason
15450
+ });
15451
+ } catch {
15452
+ }
15453
+ }
15454
+ if (ctx.config.onCircuitBreakerTrip) {
15455
+ try {
15456
+ await ctx.config.onCircuitBreakerTrip({
15457
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
15458
+ rates
15459
+ });
15460
+ } catch {
15461
+ }
15462
+ }
15151
15463
  }
15464
+ continue;
15152
15465
  }
15153
- return;
15154
- }
15155
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15156
- if (doc.status !== "degraded") {
15466
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15467
+ if (doc.status !== "degraded") {
15468
+ await ctx.collections.health.updateOne(
15469
+ { _id: doc._id },
15470
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15471
+ );
15472
+ }
15473
+ } else if (doc.status === "degraded") {
15157
15474
  await ctx.collections.health.updateOne(
15158
- { _id: "singleton" },
15159
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
15475
+ { _id: doc._id },
15476
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15160
15477
  );
15161
15478
  }
15162
- } else if (doc.status === "degraded") {
15163
- await ctx.collections.health.updateOne(
15164
- { _id: "singleton" },
15165
- { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
15166
- );
15167
15479
  }
15168
15480
  }
15481
+ async function getBucketStatus(ctx, fromEmail, kind) {
15482
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
15483
+ const id = healthBucketId(domain, kind);
15484
+ return ctx.collections.health.findOne({ _id: id });
15485
+ }
15169
15486
 
15170
15487
  // src/server/runner/send.ts
15171
15488
  async function handleSend(run, step, contact, flow, ctx) {
@@ -15249,8 +15566,8 @@ async function dispatchSend(sendId, ctx) {
15249
15566
  return;
15250
15567
  }
15251
15568
  if (send.kind === "marketing") {
15252
- const health = await ctx.collections.health.findOne({ _id: "singleton" });
15253
- if (health?.status === "tripped") {
15569
+ const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
15570
+ if (bucket?.status === "tripped") {
15254
15571
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
15255
15572
  return;
15256
15573
  }
@@ -15261,13 +15578,29 @@ async function dispatchSend(sendId, ctx) {
15261
15578
  return;
15262
15579
  }
15263
15580
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
15264
- const renderCtx = buildRenderContext(
15265
- contact,
15266
- run,
15267
- send.vars ?? {},
15268
- ctx
15269
- );
15270
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15581
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
15582
+ await ctx.collections.sends.updateOne(
15583
+ { _id: send._id },
15584
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
15585
+ );
15586
+ return;
15587
+ }
15588
+ let renderCtx;
15589
+ let rendered;
15590
+ try {
15591
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
15592
+ reason: "send",
15593
+ templateSlug: template.slug,
15594
+ flowSlug: run?.flowSlug,
15595
+ eventName: run?.triggerEvent?.name,
15596
+ eventProperties: run?.triggerEvent?.properties
15597
+ });
15598
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
15599
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15600
+ } catch (err) {
15601
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
15602
+ throw err;
15603
+ }
15271
15604
  const tracking = applyTracking(rendered.html, {
15272
15605
  sendId: String(send._id),
15273
15606
  publicUrl: ctx.config.publicUrl,
@@ -15319,13 +15652,13 @@ async function dispatchSend(sendId, ctx) {
15319
15652
  }
15320
15653
  }
15321
15654
  );
15322
- await recordHealthCounter(ctx, "sent");
15655
+ await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
15323
15656
  } catch (err) {
15324
15657
  await ctx.collections.sends.updateOne(
15325
15658
  { _id: send._id },
15326
15659
  { $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
15327
15660
  );
15328
- await recordHealthCounter(ctx, "failedToSend");
15661
+ await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
15329
15662
  if (ctx.config.onSendFailure) {
15330
15663
  try {
15331
15664
  await ctx.config.onSendFailure({ send, error: err });
@@ -15343,7 +15676,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
15343
15676
  }
15344
15677
  return ctx.config.defaultProvider;
15345
15678
  }
15346
- function buildRenderContext(contact, run, vars, ctx) {
15679
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
15347
15680
  const scope = "marketing";
15348
15681
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
15349
15682
  const token = signUnsubscribeToken(
@@ -15352,8 +15685,10 @@ function buildRenderContext(contact, run, vars, ctx) {
15352
15685
  );
15353
15686
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
15354
15687
  return {
15688
+ ...resolved,
15355
15689
  contact,
15356
15690
  vars,
15691
+ event: run?.triggerEvent?.properties ?? {},
15357
15692
  unsubscribeUrl,
15358
15693
  senderAddress: ctx.config.senderAddress
15359
15694
  };
@@ -15438,8 +15773,15 @@ async function processOneRunStep(runId, ctx) {
15438
15773
  return handleCondition(run, step, contact, ctx);
15439
15774
  case "branch":
15440
15775
  return handleBranch(run, step, contact, ctx);
15441
- case "send":
15776
+ case "send": {
15777
+ if (step.delivery) {
15778
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
15779
+ if (deliverAt.getTime() > Date.now() + 3e4) {
15780
+ return deferSendForWindow(run, deliverAt, ctx);
15781
+ }
15782
+ }
15442
15783
  return handleSend(run, step, contact, flow, ctx);
15784
+ }
15443
15785
  case "tag":
15444
15786
  return handleTag(run, step, ctx);
15445
15787
  case "fire_event":
@@ -15590,6 +15932,34 @@ async function handleWebhookStep(run, step, ctx) {
15590
15932
  }
15591
15933
  }
15592
15934
  }
15935
+ async function deferSendForWindow(run, deliverAt, ctx) {
15936
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
15937
+ // Only write once per deferral — if nextActionAt already points at (or
15938
+ // past) the slot, another worker/tick got here first.
15939
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
15940
+ {
15941
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
15942
+ $push: {
15943
+ history: {
15944
+ stepIndex: run.currentStepIndex,
15945
+ action: "send_deferred",
15946
+ at: /* @__PURE__ */ new Date(),
15947
+ details: { until: deliverAt }
15948
+ }
15949
+ }
15950
+ },
15951
+ { returnDocument: "after" }
15952
+ );
15953
+ if (!updated) return;
15954
+ await ctx.queues.advance.add(
15955
+ "advance",
15956
+ { flowRunId: String(run._id) },
15957
+ {
15958
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
15959
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
15960
+ }
15961
+ );
15962
+ }
15593
15963
  async function advanceStep(run, ctx, log, opts = {}) {
15594
15964
  const stepInc = opts.stepInc ?? 1;
15595
15965
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -15909,7 +16279,15 @@ async function promoteSoftBounces(ctx) {
15909
16279
  const cutoff = new Date(Date.now() - windowDays * 864e5);
15910
16280
  const offenders = await ctx.collections.sends.aggregate([
15911
16281
  { $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
15912
- { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
16282
+ { $sort: { queuedAt: 1 } },
16283
+ {
16284
+ $group: {
16285
+ _id: "$emailAtSend",
16286
+ count: { $sum: 1 },
16287
+ lastFromEmail: { $last: "$fromEmail" },
16288
+ lastKind: { $last: "$kind" }
16289
+ }
16290
+ },
15913
16291
  { $match: { count: { $gte: threshold } } },
15914
16292
  { $limit: 200 }
15915
16293
  ]).toArray();
@@ -15941,32 +16319,649 @@ async function promoteSoftBounces(ctx) {
15941
16319
  { emailAtSubscribe: email },
15942
16320
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
15943
16321
  );
15944
- await recordHealthCounter(ctx, "hardBounced");
16322
+ await recordHealthCounter(
16323
+ ctx,
16324
+ "hardBounced",
16325
+ o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
16326
+ );
16327
+ }
16328
+ }
16329
+ var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
16330
+ var DNS_CONCURRENCY = 8;
16331
+ var defaultResolver = {
16332
+ resolve4: (hostname) => dns__default.default.resolve4(hostname)
16333
+ };
16334
+ async function runDnsblChecks(ctx, opts = {}) {
16335
+ const cfg = ctx.config.dnsbl ?? {};
16336
+ const intervalHours = cfg.intervalHours ?? 24;
16337
+ if (!opts.force && intervalHours <= 0) {
16338
+ return { ran: false, reason: "disabled" };
16339
+ }
16340
+ const targets = collectTargets(ctx, cfg);
16341
+ if (targets.domains.length === 0 && targets.ips.length === 0) {
16342
+ return { ran: false, reason: "no_targets" };
16343
+ }
16344
+ const resolver = opts.resolver ?? defaultResolver;
16345
+ const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
16346
+ const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
16347
+ const pairs = [];
16348
+ for (const d of targets.domains) {
16349
+ for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
16350
+ }
16351
+ for (const ip of targets.ips) {
16352
+ for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
16353
+ }
16354
+ const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
16355
+ let duePairs = pairs;
16356
+ if (throttleCutoff != null) {
16357
+ const existing = await ctx.collections.dnsblChecks.find(
16358
+ {
16359
+ $or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
16360
+ },
16361
+ { projection: { target: 1, list: 1, runAt: 1 } }
16362
+ ).toArray();
16363
+ const fresh = /* @__PURE__ */ new Set();
16364
+ for (const e of existing) {
16365
+ if (new Date(e.runAt).getTime() > throttleCutoff) {
16366
+ fresh.add(`${e.target}|${e.list}`);
16367
+ }
16368
+ }
16369
+ duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
16370
+ if (duePairs.length === 0) {
16371
+ return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
16372
+ }
16373
+ }
16374
+ let listedCount = 0;
16375
+ async function processPair(p) {
16376
+ const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
16377
+ const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
16378
+ if (lookup.transient) return;
16379
+ if (lookup.result === "listed") listedCount++;
16380
+ await ctx.collections.dnsblChecks.updateOne(
16381
+ { target: p.target, list: p.list.host },
16382
+ {
16383
+ $set: {
16384
+ target: p.target,
16385
+ targetKind: p.targetKind,
16386
+ list: p.list.host,
16387
+ listLabel: p.list.label,
16388
+ result: lookup.result,
16389
+ returnCodes: lookup.returnCodes,
16390
+ errorMessage: lookup.errorMessage,
16391
+ runAt: /* @__PURE__ */ new Date()
16392
+ }
16393
+ },
16394
+ { upsert: true }
16395
+ );
16396
+ }
16397
+ let idx = 0;
16398
+ await Promise.all(
16399
+ Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
16400
+ while (idx < duePairs.length) {
16401
+ const my = idx++;
16402
+ await processPair(duePairs[my]);
16403
+ }
16404
+ })
16405
+ );
16406
+ return { ran: true, totalChecks: duePairs.length, listedCount };
16407
+ }
16408
+ function collectTargets(ctx, cfg) {
16409
+ const domains = /* @__PURE__ */ new Set();
16410
+ const registry = ctx.config.senderDomains;
16411
+ if (registry) {
16412
+ for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
16413
+ }
16414
+ const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
16415
+ if (fromDomain) domains.add(fromDomain);
16416
+ const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
16417
+ if (txnDomain) domains.add(txnDomain);
16418
+ return {
16419
+ domains: Array.from(domains),
16420
+ ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
16421
+ };
16422
+ }
16423
+ function buildQueryName(target, kind, listHost) {
16424
+ if (kind === "ip") {
16425
+ const v = net__default.default.isIP(target);
16426
+ if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
16427
+ if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
16428
+ return null;
16429
+ }
16430
+ const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
16431
+ return `${domain}.${listHost}`;
16432
+ }
16433
+ var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
16434
+ async function queryDnsbl(resolver, query) {
16435
+ try {
16436
+ const records = await resolver.resolve4(query);
16437
+ return interpretRecords(records);
16438
+ } catch (err) {
16439
+ const code = err?.code;
16440
+ if (code === "ENOTFOUND" || code === "ENODATA") {
16441
+ return { result: "clean", returnCodes: [], errorMessage: null };
16442
+ }
16443
+ if (code && TRANSIENT_DNS_CODES.has(code)) {
16444
+ return {
16445
+ transient: true,
16446
+ result: "error",
16447
+ returnCodes: [],
16448
+ errorMessage: String(err?.message ?? err)
16449
+ };
16450
+ }
16451
+ return {
16452
+ result: "error",
16453
+ returnCodes: [],
16454
+ errorMessage: String(err?.message ?? err)
16455
+ };
16456
+ }
16457
+ }
16458
+ function interpretRecords(records) {
16459
+ if (!records || records.length === 0) {
16460
+ return { result: "clean", returnCodes: [], errorMessage: null };
16461
+ }
16462
+ const errorish = records.filter((r) => r.startsWith("127.255.255."));
16463
+ if (errorish.length === records.length) {
16464
+ return {
16465
+ result: "error",
16466
+ returnCodes: records,
16467
+ errorMessage: `list returned reserved code(s): ${records.join(", ")}`
16468
+ };
15945
16469
  }
16470
+ const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
16471
+ if (listed.length > 0) {
16472
+ return { result: "listed", returnCodes: records, errorMessage: null };
16473
+ }
16474
+ return { result: "clean", returnCodes: records, errorMessage: null };
16475
+ }
16476
+ function reverseIPv4(ip) {
16477
+ return ip.split(".").reverse().join(".");
16478
+ }
16479
+ function reverseIPv6Nibbles(ip) {
16480
+ const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
16481
+ let normalized = ip;
16482
+ if (v4Match) {
16483
+ const octets = v4Match[1].split(".").map((o) => Number(o));
16484
+ if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
16485
+ const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
16486
+ const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
16487
+ normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
16488
+ }
16489
+ }
16490
+ const sides = normalized.split("::");
16491
+ let groups;
16492
+ if (sides.length === 1) {
16493
+ groups = sides[0].split(":");
16494
+ } else {
16495
+ const left = sides[0].split(":").filter(Boolean);
16496
+ const right = sides[1].split(":").filter(Boolean);
16497
+ const missing = 8 - left.length - right.length;
16498
+ groups = [...left, ...Array(missing).fill("0"), ...right];
16499
+ }
16500
+ if (groups.length !== 8) {
16501
+ throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
16502
+ }
16503
+ const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
16504
+ return padded.split("").reverse().join(".");
16505
+ }
16506
+ function registrableDomain(domain) {
16507
+ const parsed = psl__default.default.parse(domain);
16508
+ if ("domain" in parsed && parsed.domain) return parsed.domain;
16509
+ return domain;
15946
16510
  }
15947
16511
 
15948
- // src/server/runner/tick.ts
15949
- var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
15950
- async function runTick(ctx) {
16512
+ // src/server/runner/postmaster.ts
16513
+ var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
16514
+ var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
16515
+ var FETCH_TIMEOUT_MS = 15e3;
16516
+ async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
16517
+ const ctl = new AbortController();
16518
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
16519
+ try {
16520
+ return await fetcher(url, { ...init, signal: ctl.signal });
16521
+ } finally {
16522
+ clearTimeout(t);
16523
+ }
16524
+ }
16525
+ function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
16526
+ let cached = null;
16527
+ async function getAccessToken() {
16528
+ if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
16529
+ return cached.accessToken;
16530
+ }
16531
+ const body = new URLSearchParams({
16532
+ client_id: cfg.clientId,
16533
+ client_secret: cfg.clientSecret,
16534
+ refresh_token: cfg.refreshToken,
16535
+ grant_type: "refresh_token"
16536
+ });
16537
+ const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
16538
+ method: "POST",
16539
+ headers: { "content-type": "application/x-www-form-urlencoded" },
16540
+ body: body.toString()
16541
+ });
16542
+ if (!res.ok) {
16543
+ throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
16544
+ }
16545
+ const data = await res.json();
16546
+ cached = {
16547
+ accessToken: data.access_token,
16548
+ expiresAt: Date.now() + data.expires_in * 1e3
16549
+ };
16550
+ return cached.accessToken;
16551
+ }
16552
+ async function authedGet(path) {
16553
+ const token = await getAccessToken();
16554
+ const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path}`, {
16555
+ method: "GET",
16556
+ headers: { authorization: `Bearer ${token}` }
16557
+ });
16558
+ if (!res.ok) {
16559
+ throw new Error(`Postmaster GET ${path} failed: ${res.status} ${await safeText(res)}`);
16560
+ }
16561
+ return await res.json();
16562
+ }
16563
+ return {
16564
+ async listDomains() {
16565
+ const data = await authedGet("/domains");
16566
+ return data.domains ?? [];
16567
+ },
16568
+ async getLatestTrafficStats(domain) {
16569
+ const data = await authedGet(
16570
+ `/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
16571
+ );
16572
+ return data.trafficStats?.[0] ?? null;
16573
+ }
16574
+ };
16575
+ }
16576
+ async function safeText(res) {
16577
+ try {
16578
+ return await res.text();
16579
+ } catch {
16580
+ return "<no body>";
16581
+ }
16582
+ }
16583
+ async function runPostmasterPull(ctx, opts = {}) {
16584
+ const cfg = ctx.config.postmaster;
16585
+ if (!cfg) return { ran: false, reason: "not_configured" };
16586
+ const intervalHours = cfg.intervalHours ?? 24;
16587
+ if (!opts.force && intervalHours <= 0) {
16588
+ return { ran: false, reason: "disabled" };
16589
+ }
16590
+ const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
16591
+ const allDomains = resolveDomains(ctx, cfg);
16592
+ if (allDomains.length === 0) {
16593
+ return { ran: false, reason: "no_domains" };
16594
+ }
16595
+ let domains = allDomains;
16596
+ if (!opts.force) {
16597
+ const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
16598
+ const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
16599
+ const lastByDomain = /* @__PURE__ */ new Map();
16600
+ for (const s of latest) {
16601
+ const ts = new Date(s.fetchedAt).getTime();
16602
+ const cur = lastByDomain.get(s.domain) ?? 0;
16603
+ if (ts > cur) lastByDomain.set(s.domain, ts);
16604
+ }
16605
+ domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
16606
+ if (domains.length === 0) {
16607
+ return { ran: false, reason: "not_due" };
16608
+ }
16609
+ }
16610
+ const fetches = await Promise.all(
16611
+ domains.map(async (domain) => {
16612
+ try {
16613
+ const stat = await client.getLatestTrafficStats(domain);
16614
+ return { domain, stat, error: null };
16615
+ } catch (err) {
16616
+ console.error(`mailery: postmaster fetch failed for ${domain}`, err);
16617
+ return { domain, stat: null, error: err };
16618
+ }
16619
+ })
16620
+ );
16621
+ let fetched = 0;
16622
+ const trippedDomains = [];
16623
+ for (const { domain, stat } of fetches) {
16624
+ if (!stat) continue;
16625
+ const snapshot = toSnapshot(domain, stat);
16626
+ if (!snapshot) continue;
16627
+ fetched++;
16628
+ await ctx.collections.postmasterSnapshots.updateOne(
16629
+ { domain: snapshot.domain, date: snapshot.date },
16630
+ { $set: snapshot },
16631
+ { upsert: true }
16632
+ );
16633
+ if (snapshot.domainReputation === "BAD") {
16634
+ const kindsToTrip = kindsForDomain(ctx, domain);
16635
+ for (const kind of kindsToTrip) {
16636
+ const tripped = await tripBucket(ctx, domain, kind, snapshot);
16637
+ if (tripped) trippedDomains.push(`${domain}|${kind}`);
16638
+ }
16639
+ }
16640
+ }
16641
+ return { ran: true, fetched, trippedDomains };
16642
+ }
16643
+ function kindsForDomain(ctx, domain) {
16644
+ const registry = ctx.config.senderDomains ?? {};
16645
+ const entry = registry[domain.toLowerCase()];
16646
+ if (!entry) return [];
16647
+ if (entry.kind === "both") return ["marketing", "transactional"];
16648
+ return [entry.kind];
16649
+ }
16650
+ function resolveDomains(ctx, cfg) {
16651
+ if (cfg.domains && cfg.domains.length > 0) {
16652
+ return cfg.domains.map((d) => d.toLowerCase());
16653
+ }
16654
+ const out = /* @__PURE__ */ new Set();
16655
+ const registry = ctx.config.senderDomains;
16656
+ if (registry) {
16657
+ for (const d of Object.keys(registry)) out.add(d.toLowerCase());
16658
+ }
16659
+ const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
16660
+ if (f) out.add(f);
16661
+ const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
16662
+ if (t) out.add(t);
16663
+ return Array.from(out);
16664
+ }
16665
+ function toSnapshot(domain, stat) {
16666
+ const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
16667
+ if (!m) {
16668
+ console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
16669
+ return null;
16670
+ }
16671
+ const yyyymmdd = m[1];
16672
+ const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
16673
+ return {
16674
+ domain,
16675
+ date,
16676
+ domainReputation: stat.domainReputation ?? null,
16677
+ ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
16678
+ reputation: r.reputation,
16679
+ ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
16680
+ })) : null,
16681
+ userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
16682
+ spfSuccessRatio: stat.spfSuccessRatio ?? null,
16683
+ dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
16684
+ dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
16685
+ outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
16686
+ inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
16687
+ deliveryErrors: stat.deliveryErrors ?? null,
16688
+ spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
16689
+ fetchedAt: /* @__PURE__ */ new Date()
16690
+ };
16691
+ }
16692
+ async function tripBucket(ctx, domain, kind, snapshot) {
16693
+ const id = healthBucketId(domain, kind);
16694
+ const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
16695
+ const prior = await ctx.collections.health.findOne({ _id: id });
16696
+ if (prior?.status === "tripped") {
16697
+ return false;
16698
+ }
15951
16699
  await ctx.collections.health.updateOne(
15952
- { _id: "singleton" },
16700
+ { _id: id },
15953
16701
  {
15954
- $set: { updatedAt: /* @__PURE__ */ new Date() },
16702
+ $set: {
16703
+ status: "tripped",
16704
+ trippedAt: /* @__PURE__ */ new Date(),
16705
+ trippedReason: reason,
16706
+ updatedAt: /* @__PURE__ */ new Date()
16707
+ },
15955
16708
  $setOnInsert: {
15956
- _id: "singleton",
16709
+ _id: id,
16710
+ senderDomain: domain,
16711
+ kind,
15957
16712
  windowStartedAt: /* @__PURE__ */ new Date(),
15958
16713
  windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15959
- status: "healthy",
15960
- trippedAt: null,
15961
- trippedReason: null,
15962
16714
  manuallyResumedAt: null,
15963
16715
  counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15964
16716
  rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15965
16717
  }
15966
16718
  },
15967
16719
  { upsert: true }
15968
- ).catch(() => {
15969
- });
16720
+ );
16721
+ if (ctx.config.onCircuitBreakerTrip) {
16722
+ try {
16723
+ await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
16724
+ } catch {
16725
+ }
16726
+ }
16727
+ return true;
16728
+ }
16729
+
16730
+ // src/server/runner/snds.ts
16731
+ var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
16732
+ var FETCH_TIMEOUT_MS2 = 3e4;
16733
+ async function runSndsPull(ctx, opts = {}) {
16734
+ const cfg = ctx.config.snds;
16735
+ if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
16736
+ const intervalHours = cfg.intervalHours ?? 24;
16737
+ if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
16738
+ if (!opts.force) {
16739
+ const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
16740
+ if (latest[0]) {
16741
+ const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
16742
+ if (ageMs < intervalHours * 60 * 60 * 1e3) {
16743
+ return { ran: false, reason: "not_due" };
16744
+ }
16745
+ }
16746
+ }
16747
+ const fetcher = opts.fetcher ?? globalThis.fetch;
16748
+ const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
16749
+ const ctl = new AbortController();
16750
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
16751
+ let res;
16752
+ try {
16753
+ res = await fetcher(url, { method: "GET", signal: ctl.signal });
16754
+ } catch (err) {
16755
+ throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
16756
+ } finally {
16757
+ clearTimeout(t);
16758
+ }
16759
+ if (!res.ok) {
16760
+ throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
16761
+ }
16762
+ const csv = await res.text();
16763
+ const rows = parseSndsCsv(csv);
16764
+ const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
16765
+ const now = /* @__PURE__ */ new Date();
16766
+ const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
16767
+ updateOne: {
16768
+ filter: { ip: row.ip, activityStart: row.activityStart },
16769
+ update: { $set: { ...row, fetchedAt: now } },
16770
+ upsert: true
16771
+ }
16772
+ }));
16773
+ let persisted = 0;
16774
+ if (ops.length > 0) {
16775
+ const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
16776
+ persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
16777
+ }
16778
+ return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
16779
+ }
16780
+ function redactKey(s, key) {
16781
+ if (!key) return s;
16782
+ return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
16783
+ }
16784
+ async function safeText2(res) {
16785
+ try {
16786
+ return await res.text();
16787
+ } catch {
16788
+ return "<no body>";
16789
+ }
16790
+ }
16791
+ function parseSndsCsv(csv) {
16792
+ const out = [];
16793
+ for (const raw of csv.split(/\r?\n/)) {
16794
+ if (!raw.trim()) continue;
16795
+ const fields = splitCsvRow(raw);
16796
+ if (fields.length < 7) continue;
16797
+ const parsed = parseRow(fields);
16798
+ if (parsed) out.push(parsed);
16799
+ }
16800
+ return out;
16801
+ }
16802
+ function splitCsvRow(row) {
16803
+ const out = [];
16804
+ let cur = "";
16805
+ let inQuotes = false;
16806
+ for (let i = 0; i < row.length; i++) {
16807
+ const ch = row[i];
16808
+ if (inQuotes) {
16809
+ if (ch === '"') {
16810
+ if (row[i + 1] === '"') {
16811
+ cur += '"';
16812
+ i++;
16813
+ } else {
16814
+ inQuotes = false;
16815
+ }
16816
+ } else {
16817
+ cur += ch;
16818
+ }
16819
+ } else if (ch === '"') {
16820
+ inQuotes = true;
16821
+ } else if (ch === ",") {
16822
+ out.push(cur);
16823
+ cur = "";
16824
+ } else {
16825
+ cur += ch;
16826
+ }
16827
+ }
16828
+ out.push(cur);
16829
+ return out.map((f) => f.trim());
16830
+ }
16831
+ function parseRow(fields) {
16832
+ const [
16833
+ ip,
16834
+ activityStart,
16835
+ activityEnd,
16836
+ rcptCommands,
16837
+ dataCommands,
16838
+ messageRecipients,
16839
+ filterResult,
16840
+ complaintRate,
16841
+ trapMessageCount,
16842
+ sampleHelo,
16843
+ sampleMailFrom
16844
+ ] = fields;
16845
+ const start = parseSndsDate(activityStart ?? "");
16846
+ const end = parseSndsDate(activityEnd ?? "");
16847
+ if (!ip || !start || !end) return null;
16848
+ return {
16849
+ ip,
16850
+ activityStart: start,
16851
+ activityEnd: end,
16852
+ rcptCommands: toInt(rcptCommands),
16853
+ dataCommands: toInt(dataCommands),
16854
+ messageRecipients: toInt(messageRecipients),
16855
+ filterResult: normalizeFilter(filterResult ?? ""),
16856
+ complaintRate: parseComplaintRate(complaintRate ?? ""),
16857
+ trapMessageCount: toInt(trapMessageCount),
16858
+ sampleHelo: sampleHelo ? sampleHelo : null,
16859
+ sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
16860
+ };
16861
+ }
16862
+ function toInt(s) {
16863
+ if (!s) return 0;
16864
+ const n = Number(s.replace(/[^\d-]/g, ""));
16865
+ return Number.isFinite(n) ? n : 0;
16866
+ }
16867
+ function normalizeFilter(s) {
16868
+ const u = s.toUpperCase().trim();
16869
+ if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
16870
+ return "UNKNOWN";
16871
+ }
16872
+ function parseComplaintRate(s) {
16873
+ const trimmed = s.trim();
16874
+ if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
16875
+ const cleaned = trimmed.replace(/%/g, "");
16876
+ let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16877
+ if (m) return Number(m[1]) / 100;
16878
+ m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16879
+ if (m) return Number(m[1]) / 100;
16880
+ m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
16881
+ if (m) return Number(m[2]) / 100;
16882
+ m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
16883
+ if (m) return Number(m[1]) / 100;
16884
+ return null;
16885
+ }
16886
+ function parseSndsDate(s) {
16887
+ const trimmed = s.trim();
16888
+ if (!trimmed) return null;
16889
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
16890
+ if (m) {
16891
+ const month = Number(m[1]) - 1;
16892
+ const day = Number(m[2]);
16893
+ const year = Number(m[3]);
16894
+ let hour = Number(m[4]);
16895
+ const minute = Number(m[5]);
16896
+ const second = Number(m[6] ?? 0);
16897
+ const meridiem = m[7]?.toUpperCase();
16898
+ if (meridiem === "PM" && hour < 12) hour += 12;
16899
+ if (meridiem === "AM" && hour === 12) hour = 0;
16900
+ const wallMs = Date.UTC(year, month, day, hour, minute, second);
16901
+ if (!Number.isFinite(wallMs)) return null;
16902
+ const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
16903
+ return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
16904
+ }
16905
+ const d = new Date(trimmed);
16906
+ if (!isNaN(d.getTime())) return d;
16907
+ return null;
16908
+ }
16909
+ function isUsPacificDst(year, month0, day) {
16910
+ if (month0 > 2 && month0 < 10) return true;
16911
+ if (month0 < 2 || month0 > 10) return false;
16912
+ if (month0 === 2) {
16913
+ const dst2 = nthSundayOfMonth(year, 2, 2);
16914
+ return day >= dst2;
16915
+ }
16916
+ const dst = nthSundayOfMonth(year, 10, 1);
16917
+ return day < dst;
16918
+ }
16919
+ function nthSundayOfMonth(year, month0, n) {
16920
+ const first = new Date(Date.UTC(year, month0, 1));
16921
+ const dayOfWeek = first.getUTCDay();
16922
+ const firstSunday = 1 + (7 - dayOfWeek) % 7;
16923
+ return firstSunday + (n - 1) * 7;
16924
+ }
16925
+ var _lastPruneAt = 0;
16926
+ var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
16927
+ async function pruneDmarcFailures(ctx, opts = {}) {
16928
+ const cfg = ctx.config.dmarc;
16929
+ const days = cfg?.retentionDays ?? 90;
16930
+ if (days <= 0) return 0;
16931
+ if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
16932
+ _lastPruneAt = Date.now();
16933
+ const cutoff = new Date(Date.now() - days * 864e5);
16934
+ const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
16935
+ return r.deletedCount ?? 0;
16936
+ }
16937
+
16938
+ // src/server/runner/tick.ts
16939
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
16940
+ async function runTick(ctx) {
16941
+ try {
16942
+ await ctx.collections.health.updateOne(
16943
+ { _id: HEALTH_AGG_ID },
16944
+ {
16945
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
16946
+ $setOnInsert: {
16947
+ _id: HEALTH_AGG_ID,
16948
+ senderDomain: null,
16949
+ kind: null,
16950
+ windowStartedAt: /* @__PURE__ */ new Date(),
16951
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
16952
+ status: "healthy",
16953
+ trippedAt: null,
16954
+ trippedReason: null,
16955
+ manuallyResumedAt: null,
16956
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
16957
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
16958
+ }
16959
+ },
16960
+ { upsert: true }
16961
+ );
16962
+ } catch (err) {
16963
+ console.error("mailery: heartbeat write failed", err);
16964
+ }
15970
16965
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15971
16966
  console.error("mailery: triggers scan failed", err);
15972
16967
  });
@@ -15988,6 +16983,20 @@ async function runTick(ctx) {
15988
16983
  await promoteSoftBounces(ctx).catch((err) => {
15989
16984
  console.error("mailery: soft-bounce promotion failed", err);
15990
16985
  });
16986
+ await Promise.all([
16987
+ runDnsblChecks(ctx).catch((err) => {
16988
+ console.error("mailery: dnsbl checks failed", err);
16989
+ }),
16990
+ runPostmasterPull(ctx).catch((err) => {
16991
+ console.error("mailery: postmaster pull failed", err);
16992
+ }),
16993
+ runSndsPull(ctx).catch((err) => {
16994
+ console.error("mailery: snds pull failed", err);
16995
+ }),
16996
+ pruneDmarcFailures(ctx).catch((err) => {
16997
+ console.error("mailery: dmarc prune failed", err);
16998
+ })
16999
+ ]);
15991
17000
  }
15992
17001
  async function sweepStrandedSends(ctx) {
15993
17002
  const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
@@ -16045,6 +17054,10 @@ async function processScheduledBroadcasts2(ctx) {
16045
17054
  }
16046
17055
 
16047
17056
  // src/server/runner/webhook.ts
17057
+ function dimsFromSend(send) {
17058
+ if (!send) return null;
17059
+ return { fromEmail: send.fromEmail, kind: send.kind };
17060
+ }
16048
17061
  async function applyWebhookEvent(event, ctx) {
16049
17062
  const send = await ctx.collections.sends.findOne(
16050
17063
  event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
@@ -16058,7 +17071,7 @@ async function applyWebhookEvent(event, ctx) {
16058
17071
  { $set: { status: "delivered", deliveredAt: event.occurredAt } }
16059
17072
  );
16060
17073
  }
16061
- await recordHealthCounter(ctx, "delivered");
17074
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
16062
17075
  break;
16063
17076
  case "open":
16064
17077
  if (send) {
@@ -16113,8 +17126,11 @@ async function applyWebhookEvent(event, ctx) {
16113
17126
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
16114
17127
  );
16115
17128
  }
16116
- await recordHealthCounter(ctx, "bounced");
16117
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
17129
+ {
17130
+ const dims = dimsFromSend(send);
17131
+ await recordHealthCounter(ctx, "bounced", dims);
17132
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
17133
+ }
16118
17134
  break;
16119
17135
  }
16120
17136
  case "complaint":
@@ -16130,7 +17146,7 @@ async function applyWebhookEvent(event, ctx) {
16130
17146
  { emailAtSubscribe: event.email },
16131
17147
  { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
16132
17148
  );
16133
- await recordHealthCounter(ctx, "complained");
17149
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
16134
17150
  break;
16135
17151
  case "unsubscribe":
16136
17152
  if (send) {
@@ -16196,10 +17212,12 @@ var Mailer = class _Mailer {
16196
17212
  db: this.db,
16197
17213
  collections: this.collections,
16198
17214
  adapter: this.adapter,
17215
+ varsAdapter: this.config.varsAdapter,
16199
17216
  providers: this.providers,
16200
17217
  queues: this.queues,
16201
17218
  config: this.config,
16202
- handlebarsHelpers: this.config.handlebarsHelpers
17219
+ handlebarsHelpers: this.config.handlebarsHelpers,
17220
+ audit: (entry) => this.audit(entry)
16203
17221
  };
16204
17222
  }
16205
17223
  /**
@@ -16274,6 +17292,10 @@ var Mailer = class _Mailer {
16274
17292
  if (!config.providers[config.defaultProvider]) {
16275
17293
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
16276
17294
  }
17295
+ if (config.varsAdapter) {
17296
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
17297
+ assertNoReservedVarKeys2(config.varsAdapter);
17298
+ }
16277
17299
  const collections = getCollections(config.db, config.collectionPrefix);
16278
17300
  await ensureIndexes(config.db, config.collectionPrefix);
16279
17301
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -16489,6 +17511,72 @@ var Mailer = class _Mailer {
16489
17511
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
16490
17512
  }
16491
17513
  }
17514
+ // -------------------------------------------------------------------------
17515
+ // Flow abort
17516
+ // -------------------------------------------------------------------------
17517
+ /**
17518
+ * Abort every active run of one flow for a contact, immediately. Runs parked
17519
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
17520
+ * no-op. Also cancels any of the flow's emails still sitting in the send
17521
+ * queue for this contact (queued or awaiting retry), so an abort means no
17522
+ * further mail, not just no further steps.
17523
+ *
17524
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
17525
+ * same handler that processes the business event ("user upgraded").
17526
+ */
17527
+ async abortFlow(flowSlug, externalId, opts = {}) {
17528
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17529
+ const flow = await this.collections.flows.findOne(
17530
+ { slug: parsed.flowSlug },
17531
+ { projection: { _id: 1 } }
17532
+ );
17533
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17534
+ const result = await this.abortActiveRuns(
17535
+ { externalId: parsed.externalId, flowId: flow._id },
17536
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17537
+ );
17538
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17539
+ await this.audit({
17540
+ actor: "host",
17541
+ action: "flow.abort",
17542
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17543
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17544
+ });
17545
+ }
17546
+ return result;
17547
+ }
17548
+ /**
17549
+ * Abort every active flow run for a contact across all flows. Same semantics
17550
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
17551
+ */
17552
+ async abortAllFlows(externalId, opts = {}) {
17553
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
17554
+ const result = await this.abortActiveRuns(
17555
+ { externalId: parsed.externalId },
17556
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17557
+ );
17558
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17559
+ await this.audit({
17560
+ actor: "host",
17561
+ action: "flow.abort_all",
17562
+ resource: { collection: "mailer_flow_runs" },
17563
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17564
+ });
17565
+ }
17566
+ return result;
17567
+ }
17568
+ async abortActiveRuns(filter, exitReason) {
17569
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
17570
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
17571
+ for (const run of runs) {
17572
+ await exitFlowRun(run, exitReason, this.runnerContext);
17573
+ }
17574
+ const cancelled = await this.collections.sends.updateMany(
17575
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
17576
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
17577
+ );
17578
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
17579
+ }
16492
17580
  /**
16493
17581
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
16494
17582
  * suppression row to block re-import. INVARIANT 9.