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/index.js CHANGED
@@ -1,18 +1,28 @@
1
+ import { z } from 'zod';
1
2
  import { 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
  import express, { Router } from 'express';
13
+ import multer from 'multer';
10
14
  import path from 'path';
11
15
  import { fileURLToPath } from 'url';
12
16
  import fs from 'fs';
13
17
 
14
18
  var __defProp = Object.defineProperty;
15
19
  var __getOwnPropNames = Object.getOwnPropertyNames;
20
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
21
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
22
+ }) : x)(function(x) {
23
+ if (typeof require !== "undefined") return require.apply(this, arguments);
24
+ throw Error('Dynamic require of "' + x + '" is not supported');
25
+ });
16
26
  var __esm = (fn, res) => function __init() {
17
27
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
18
28
  };
@@ -21,6 +31,55 @@ var __export = (target, all) => {
21
31
  __defProp(target, name, { get: all[name], enumerable: true });
22
32
  };
23
33
 
34
+ // src/server/adapters/vars.ts
35
+ var vars_exports = {};
36
+ __export(vars_exports, {
37
+ RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
38
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
39
+ defineVars: () => defineVars,
40
+ resolveVars: () => resolveVars,
41
+ varsJsonSchema: () => varsJsonSchema
42
+ });
43
+ function defineVars(adapter) {
44
+ return adapter;
45
+ }
46
+ function assertNoReservedVarKeys(adapter) {
47
+ const json = varsJsonSchema(adapter);
48
+ const props = json && typeof json === "object" ? json.properties : void 0;
49
+ if (!props) return;
50
+ const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
51
+ if (clashes.length > 0) {
52
+ throw new Error(
53
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
54
+ );
55
+ }
56
+ }
57
+ function varsJsonSchema(adapter) {
58
+ return z.toJSONSchema(adapter.schema, { io: "output" });
59
+ }
60
+ async function resolveVars(adapter, contact, info) {
61
+ if (!adapter) return {};
62
+ const resolved = await adapter.resolve(contact, info);
63
+ if (!resolved || typeof resolved !== "object") return {};
64
+ const out = { ...resolved };
65
+ for (const k of RESERVED_VAR_KEYS) delete out[k];
66
+ return out;
67
+ }
68
+ var RESERVED_VAR_KEYS;
69
+ var init_vars = __esm({
70
+ "src/server/adapters/vars.ts"() {
71
+ RESERVED_VAR_KEYS = [
72
+ "contact",
73
+ "vars",
74
+ "event",
75
+ "unsubscribeUrl",
76
+ "viewInBrowserUrl",
77
+ "preferenceCenterUrl",
78
+ "senderAddress"
79
+ ];
80
+ }
81
+ });
82
+
24
83
  // src/server/adapters/mongo.ts
25
84
  var mongo_exports = {};
26
85
  __export(mongo_exports, {
@@ -334,6 +393,12 @@ var tagInputSchema = z.object({
334
393
  externalId: externalIdSchema,
335
394
  tag: z.string().min(1).max(128)
336
395
  });
396
+ var abortFlowInputSchema = z.object({
397
+ flowSlug: slugSchema,
398
+ externalId: externalIdSchema,
399
+ reason: z.string().min(1).max(200).optional()
400
+ });
401
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
337
402
  var sendOneOffInputSchema = z.object({
338
403
  templateSlug: slugSchema,
339
404
  externalId: externalIdSchema,
@@ -363,7 +428,13 @@ var flowStepSchema = z.lazy(
363
428
  type: z.literal("send"),
364
429
  templateSlug: slugSchema,
365
430
  providerOverride: z.string().optional(),
366
- vars: z.record(z.string(), z.unknown()).optional()
431
+ vars: z.record(z.string(), z.unknown()).optional(),
432
+ delivery: z.object({
433
+ weekdaysOnly: z.boolean().optional(),
434
+ timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
435
+ useContactTimezone: z.boolean().optional(),
436
+ timezone: z.string().optional()
437
+ }).optional()
367
438
  }),
368
439
  z.object({
369
440
  type: z.literal("tag"),
@@ -447,6 +518,17 @@ var predicateSchema = z.lazy(
447
518
  );
448
519
 
449
520
  // src/server/config.ts
521
+ var DEFAULT_DOMAIN_DNSBL_LISTS = [
522
+ { host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
523
+ { host: "multi.surbl.org", label: "SURBL" },
524
+ { host: "multi.uribl.com", label: "URIBL" }
525
+ ];
526
+ var DEFAULT_IP_DNSBL_LISTS = [
527
+ { host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
528
+ { host: "b.barracudacentral.org", label: "Barracuda" },
529
+ { host: "dnsbl.sorbs.net", label: "SORBS" },
530
+ { host: "bl.spamcop.net", label: "SpamCop" }
531
+ ];
450
532
  var DEFAULTS = {
451
533
  collectionPrefix: "mailer_",
452
534
  requireDoubleOptIn: false,
@@ -507,6 +589,10 @@ function resolveConfig(c) {
507
589
  }
508
590
 
509
591
  // src/server/models/index.ts
592
+ var HEALTH_AGG_ID = "agg";
593
+ function healthBucketId(senderDomain, kind) {
594
+ return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
595
+ }
510
596
  function getCollections(db, prefix = "mailer_") {
511
597
  return {
512
598
  subscriptions: db.collection(`${prefix}subscriptions`),
@@ -524,7 +610,14 @@ function getCollections(db, prefix = "mailer_") {
524
610
  auditLog: db.collection(`${prefix}audit_log`),
525
611
  webhookEvents: db.collection(`${prefix}webhook_events`),
526
612
  health: db.collection(`${prefix}health`),
527
- contactTags: db.collection(`${prefix}contact_tags`)
613
+ contactTags: db.collection(`${prefix}contact_tags`),
614
+ dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
615
+ postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
616
+ sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
617
+ dmarcReports: db.collection(`${prefix}dmarc_reports`),
618
+ dmarcFailures: db.collection(`${prefix}dmarc_failures`),
619
+ dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
620
+ mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
528
621
  };
529
622
  }
530
623
  async function ensureIndexes(db, prefix = "mailer_") {
@@ -596,8 +689,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
596
689
  c.contactTags.createIndexes([
597
690
  { key: { externalId: 1, tag: 1 }, unique: true },
598
691
  { key: { tag: 1 } }
692
+ ]),
693
+ c.health.createIndexes([
694
+ { key: { senderDomain: 1, kind: 1 } },
695
+ { key: { status: 1 } },
696
+ // setup-status reads the most-recently-touched health doc as a heartbeat.
697
+ { key: { updatedAt: -1 } }
698
+ ]),
699
+ c.dnsblChecks.createIndexes([
700
+ { key: { target: 1, list: 1 }, unique: true },
701
+ // Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
702
+ { key: { result: 1, target: 1, list: 1 } },
703
+ // TTL — stale rows for targets the operator removed disappear after
704
+ // 60 days without manual cleanup. The puller refreshes runAt on
705
+ // every active target so existing targets stay indefinitely.
706
+ { key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
707
+ ]),
708
+ c.postmasterSnapshots.createIndexes([
709
+ { key: { domain: 1, date: 1 }, unique: true },
710
+ { key: { domain: 1, fetchedAt: -1 } },
711
+ { key: { domainReputation: 1 } }
712
+ ]),
713
+ c.sndsSnapshots.createIndexes([
714
+ { key: { ip: 1, activityStart: 1 }, unique: true },
715
+ { key: { ip: 1, fetchedAt: -1 } },
716
+ { key: { filterResult: 1 } }
717
+ ]),
718
+ c.dmarcReports.createIndexes([
719
+ { key: { reportId: 1, orgName: 1 }, unique: true },
720
+ { key: { domain: 1, rangeEnd: -1 } },
721
+ // Cross-domain "most recent reports" queries scan a lot without this.
722
+ { key: { rangeEnd: -1 } },
723
+ { key: { receivedAt: -1 } }
724
+ ]),
725
+ c.dmarcFailures.createIndexes([
726
+ { key: { reportId: 1, sourceIp: 1 }, unique: true },
727
+ { key: { domain: 1, day: -1 } },
728
+ { key: { sourceIp: 1, day: -1 } },
729
+ { key: { receivedAt: 1 } }
730
+ // for retention pruning
731
+ ]),
732
+ c.dmarcSourceTags.createIndexes([
733
+ { key: { ip: 1 }, unique: true }
734
+ ]),
735
+ c.mailTesterScores.createIndexes([
736
+ { key: { contentKey: 1 }, unique: true },
737
+ { key: { templateSlug: 1, fetchedAt: -1 } },
738
+ // TTL — Mongo auto-deletes expired scores so we never serve stale data.
739
+ { key: { expiresAt: 1 }, expireAfterSeconds: 0 }
599
740
  ])
600
741
  ]);
742
+ await c.health.deleteOne({ _id: "singleton" }).catch(() => {
743
+ });
601
744
  }
602
745
  var EventRegistry = class {
603
746
  policies = /* @__PURE__ */ new Map();
@@ -610,6 +753,9 @@ var EventRegistry = class {
610
753
  policy(name) {
611
754
  return this.policies.get(name);
612
755
  }
756
+ list() {
757
+ return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
758
+ }
613
759
  /**
614
760
  * Derive a dedupeKey for an event call. Returns null when no policy is
615
761
  * registered AND no key was passed — caller should throw.
@@ -1078,6 +1224,7 @@ async function tryEnterFlow(flow, event, ctx) {
1078
1224
  flowSlug: flow.slug,
1079
1225
  flowVersion: flow.version,
1080
1226
  emailAtEntry: sub.emailAtSubscribe,
1227
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
1081
1228
  enteredAt: /* @__PURE__ */ new Date(),
1082
1229
  status: "active",
1083
1230
  currentStepIndex: 0,
@@ -1175,6 +1322,105 @@ function effectiveLowerBound(ctx, opts) {
1175
1322
  }
1176
1323
  return null;
1177
1324
  }
1325
+
1326
+ // src/server/runner/delivery-window.ts
1327
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
1328
+ function computeDeliveryTime(now, window, contactTimezone) {
1329
+ const tz = pickTimezone(window, contactTimezone);
1330
+ let candidate = now;
1331
+ if (window.timeOfDay) {
1332
+ const [hh, mm] = window.timeOfDay.split(":").map(Number);
1333
+ const local = localParts(candidate, tz);
1334
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
1335
+ if (candidate.getTime() < todaySlot.getTime()) {
1336
+ candidate = todaySlot;
1337
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
1338
+ const next = addLocalDays(local, 1);
1339
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
1340
+ }
1341
+ }
1342
+ if (window.weekdaysOnly) {
1343
+ for (let guard = 0; guard < 3; guard++) {
1344
+ const local = localParts(candidate, tz);
1345
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
1346
+ const shift = local.weekday === "Sat" ? 2 : 1;
1347
+ const moved = addLocalDays(local, shift);
1348
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
1349
+ }
1350
+ }
1351
+ return candidate;
1352
+ }
1353
+ function pickTimezone(window, contactTimezone) {
1354
+ const candidates = [
1355
+ window.useContactTimezone ? contactTimezone : void 0,
1356
+ window.timezone,
1357
+ "UTC"
1358
+ ];
1359
+ for (const tz of candidates) {
1360
+ if (tz && isValidTimezone(tz)) return tz;
1361
+ }
1362
+ return "UTC";
1363
+ }
1364
+ var validatedZones = /* @__PURE__ */ new Map();
1365
+ function isValidTimezone(tz) {
1366
+ const cached = validatedZones.get(tz);
1367
+ if (cached !== void 0) return cached;
1368
+ let ok = true;
1369
+ try {
1370
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
1371
+ } catch {
1372
+ ok = false;
1373
+ }
1374
+ validatedZones.set(tz, ok);
1375
+ return ok;
1376
+ }
1377
+ var partFormatters = /* @__PURE__ */ new Map();
1378
+ function formatterFor(tz) {
1379
+ let f = partFormatters.get(tz);
1380
+ if (!f) {
1381
+ f = new Intl.DateTimeFormat("en-US", {
1382
+ timeZone: tz,
1383
+ year: "numeric",
1384
+ month: "2-digit",
1385
+ day: "2-digit",
1386
+ hour: "2-digit",
1387
+ minute: "2-digit",
1388
+ second: "2-digit",
1389
+ weekday: "short",
1390
+ hour12: false
1391
+ });
1392
+ partFormatters.set(tz, f);
1393
+ }
1394
+ return f;
1395
+ }
1396
+ function localParts(date, tz) {
1397
+ const parts = {};
1398
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
1399
+ return {
1400
+ y: Number(parts.year),
1401
+ mo: Number(parts.month),
1402
+ d: Number(parts.day),
1403
+ hh: Number(parts.hour) % 24,
1404
+ // Intl emits '24' for midnight in some locales
1405
+ mi: Number(parts.minute),
1406
+ ss: Number(parts.second),
1407
+ weekday: parts.weekday
1408
+ };
1409
+ }
1410
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
1411
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
1412
+ for (let i = 0; i < 2; i++) {
1413
+ const p = localParts(new Date(ts), tz);
1414
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
1415
+ const offset = asUtc - ts;
1416
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
1417
+ }
1418
+ return new Date(ts);
1419
+ }
1420
+ function addLocalDays(p, days) {
1421
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
1422
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
1423
+ }
1178
1424
  async function compileTemplate(mjml) {
1179
1425
  const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
1180
1426
  const plainText = derivePlaintext(out.html);
@@ -1298,6 +1544,9 @@ function makeHandlebars(extra) {
1298
1544
  return hb;
1299
1545
  }
1300
1546
 
1547
+ // src/server/runner/send.ts
1548
+ init_vars();
1549
+
1301
1550
  // src/server/runner/suppression.ts
1302
1551
  var SCOPES_BY_KIND = {
1303
1552
  marketing: ["all", "marketing"],
@@ -1320,21 +1569,88 @@ async function isSuppressed(collections, email, kind) {
1320
1569
  return { suppressed: false };
1321
1570
  }
1322
1571
 
1572
+ // src/server/templates/sender-domain.ts
1573
+ function validateSenderDomain(fromEmail, templateKind, registry) {
1574
+ if (!registry || Object.keys(registry).length === 0) return { ok: true };
1575
+ const domain = extractDomain(fromEmail);
1576
+ if (!domain) {
1577
+ return {
1578
+ ok: false,
1579
+ code: "invalid_email",
1580
+ reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
1581
+ };
1582
+ }
1583
+ const entry = registry[domain];
1584
+ if (!entry) {
1585
+ const known = Object.keys(registry).join(", ");
1586
+ return {
1587
+ ok: false,
1588
+ code: "unregistered_domain",
1589
+ reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
1590
+ };
1591
+ }
1592
+ if (entry.kind === "both") return { ok: true };
1593
+ if (entry.kind !== templateKind) {
1594
+ return {
1595
+ ok: false,
1596
+ code: "wrong_kind",
1597
+ reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
1598
+ };
1599
+ }
1600
+ return { ok: true };
1601
+ }
1602
+ function extractDomain(email) {
1603
+ if (typeof email !== "string") return null;
1604
+ const at = email.lastIndexOf("@");
1605
+ if (at <= 0 || at === email.length - 1) return null;
1606
+ return email.slice(at + 1).toLowerCase().trim();
1607
+ }
1608
+
1323
1609
  // src/server/runner/health.ts
1324
- async function recordHealthCounter(ctx, counter2, by = 1) {
1610
+ var ZERO_COUNTERS = {
1611
+ sent: 0,
1612
+ delivered: 0,
1613
+ bounced: 0,
1614
+ hardBounced: 0,
1615
+ softBounced: 0,
1616
+ complained: 0,
1617
+ failedToSend: 0
1618
+ };
1619
+ var ZERO_RATES = {
1620
+ bounceRate: 0,
1621
+ hardBounceRate: 0,
1622
+ complaintRate: 0,
1623
+ failureRate: 0
1624
+ };
1625
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
1626
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
1627
+ const writes = [];
1628
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
1629
+ if (dims) {
1630
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
1631
+ if (domain) {
1632
+ const id = healthBucketId(domain, dims.kind);
1633
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
1634
+ }
1635
+ }
1636
+ await Promise.all(writes);
1637
+ }
1638
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
1325
1639
  await ctx.collections.health.updateOne(
1326
- { _id: "singleton" },
1640
+ { _id },
1327
1641
  {
1328
1642
  $inc: { [`counters.${counter2}`]: by },
1329
1643
  $setOnInsert: {
1330
- _id: "singleton",
1644
+ _id,
1645
+ senderDomain,
1646
+ kind,
1331
1647
  windowStartedAt: /* @__PURE__ */ new Date(),
1332
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
1648
+ windowDurationMs: windowMs,
1333
1649
  status: "healthy",
1334
1650
  trippedAt: null,
1335
1651
  trippedReason: null,
1336
1652
  manuallyResumedAt: null,
1337
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
1653
+ rates: { ...ZERO_RATES }
1338
1654
  },
1339
1655
  $set: { updatedAt: /* @__PURE__ */ new Date() }
1340
1656
  },
@@ -1344,73 +1660,116 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
1344
1660
  async function evaluateHealth(ctx) {
1345
1661
  const cb = ctx.config.circuitBreaker;
1346
1662
  const windowMs = cb.windowMinutes * 60 * 1e3;
1347
- const doc = await ctx.collections.health.findOne({ _id: "singleton" });
1348
- if (!doc) return;
1349
- const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
1350
- if (windowAge > windowMs && doc.status !== "tripped") {
1351
- await ctx.collections.health.updateOne(
1352
- { _id: "singleton" },
1353
- {
1354
- $set: {
1355
- windowStartedAt: /* @__PURE__ */ new Date(),
1356
- windowDurationMs: windowMs,
1357
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
1358
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 },
1359
- updatedAt: /* @__PURE__ */ new Date()
1663
+ const docs = await ctx.collections.health.find({}).toArray();
1664
+ if (docs.length === 0) return;
1665
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
1666
+ if (!hasAgg) {
1667
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
1668
+ }
1669
+ for (const doc of docs) {
1670
+ const isAgg = doc._id === HEALTH_AGG_ID;
1671
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
1672
+ if (windowAge > windowMs && doc.status !== "tripped") {
1673
+ await ctx.collections.health.updateOne(
1674
+ { _id: doc._id },
1675
+ {
1676
+ $set: {
1677
+ windowStartedAt: /* @__PURE__ */ new Date(),
1678
+ windowDurationMs: windowMs,
1679
+ counters: { ...ZERO_COUNTERS },
1680
+ rates: { ...ZERO_RATES },
1681
+ status: "healthy",
1682
+ updatedAt: /* @__PURE__ */ new Date()
1683
+ }
1360
1684
  }
1361
- }
1362
- );
1363
- return;
1364
- }
1365
- const c = doc.counters;
1366
- const total = c.sent || 1;
1367
- const rates = {
1368
- bounceRate: c.bounced / total,
1369
- hardBounceRate: c.hardBounced / total,
1370
- complaintRate: c.complained / total,
1371
- failureRate: c.failedToSend / total
1372
- };
1373
- await ctx.collections.health.updateOne(
1374
- { _id: "singleton" },
1375
- { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
1376
- );
1377
- if (c.sent < cb.minSendsBeforeEval) return;
1378
- if (doc.status === "tripped") return;
1379
- let trippedReason = null;
1380
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
1381
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
1382
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
1383
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
1384
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
1385
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
1386
- }
1387
- if (trippedReason) {
1685
+ );
1686
+ continue;
1687
+ }
1688
+ const c = doc.counters;
1689
+ const total = c.sent || 1;
1690
+ const rates = {
1691
+ bounceRate: c.bounced / total,
1692
+ hardBounceRate: c.hardBounced / total,
1693
+ complaintRate: c.complained / total,
1694
+ failureRate: c.failedToSend / total
1695
+ };
1388
1696
  await ctx.collections.health.updateOne(
1389
- { _id: "singleton" },
1390
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
1697
+ { _id: doc._id },
1698
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
1391
1699
  );
1392
- if (ctx.config.onCircuitBreakerTrip) {
1393
- try {
1394
- await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
1395
- } catch {
1700
+ if (isAgg) continue;
1701
+ if (c.sent < cb.minSendsBeforeEval) continue;
1702
+ if (doc.status === "tripped") continue;
1703
+ let trippedReason = null;
1704
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
1705
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
1706
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
1707
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
1708
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
1709
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
1710
+ }
1711
+ if (trippedReason) {
1712
+ const result = await ctx.collections.health.updateOne(
1713
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
1714
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
1715
+ );
1716
+ if (result.modifiedCount > 0) {
1717
+ if (ctx.audit) {
1718
+ try {
1719
+ await ctx.audit({
1720
+ actor: "system:circuit-breaker",
1721
+ action: "health.trip",
1722
+ resource: {
1723
+ collection: "mailer_health",
1724
+ id: String(doc._id),
1725
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
1726
+ },
1727
+ diffSummary: trippedReason
1728
+ });
1729
+ } catch {
1730
+ }
1731
+ }
1732
+ if (ctx.config.onCircuitBreakerTrip) {
1733
+ try {
1734
+ await ctx.config.onCircuitBreakerTrip({
1735
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
1736
+ rates
1737
+ });
1738
+ } catch {
1739
+ }
1740
+ }
1396
1741
  }
1742
+ continue;
1397
1743
  }
1398
- return;
1399
- }
1400
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
1401
- if (doc.status !== "degraded") {
1744
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
1745
+ if (doc.status !== "degraded") {
1746
+ await ctx.collections.health.updateOne(
1747
+ { _id: doc._id },
1748
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
1749
+ );
1750
+ }
1751
+ } else if (doc.status === "degraded") {
1402
1752
  await ctx.collections.health.updateOne(
1403
- { _id: "singleton" },
1404
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
1753
+ { _id: doc._id },
1754
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
1405
1755
  );
1406
1756
  }
1407
- } else if (doc.status === "degraded") {
1408
- await ctx.collections.health.updateOne(
1409
- { _id: "singleton" },
1410
- { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
1411
- );
1412
1757
  }
1413
1758
  }
1759
+ async function getBucketStatus(ctx, fromEmail, kind) {
1760
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
1761
+ const id = healthBucketId(domain, kind);
1762
+ return ctx.collections.health.findOne({ _id: id });
1763
+ }
1764
+ function effectiveOverallStatus(docs) {
1765
+ const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
1766
+ if (buckets.length === 0) {
1767
+ return docs.length === 0 ? null : "healthy";
1768
+ }
1769
+ if (buckets.some((d) => d.status === "tripped")) return "tripped";
1770
+ if (buckets.some((d) => d.status === "degraded")) return "degraded";
1771
+ return "healthy";
1772
+ }
1414
1773
 
1415
1774
  // src/server/runner/send.ts
1416
1775
  async function handleSend(run, step, contact, flow, ctx) {
@@ -1494,8 +1853,8 @@ async function dispatchSend(sendId, ctx) {
1494
1853
  return;
1495
1854
  }
1496
1855
  if (send.kind === "marketing") {
1497
- const health = await ctx.collections.health.findOne({ _id: "singleton" });
1498
- if (health?.status === "tripped") {
1856
+ const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
1857
+ if (bucket?.status === "tripped") {
1499
1858
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
1500
1859
  return;
1501
1860
  }
@@ -1506,13 +1865,29 @@ async function dispatchSend(sendId, ctx) {
1506
1865
  return;
1507
1866
  }
1508
1867
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
1509
- const renderCtx = buildRenderContext(
1510
- contact,
1511
- run,
1512
- send.vars ?? {},
1513
- ctx
1514
- );
1515
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1868
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
1869
+ await ctx.collections.sends.updateOne(
1870
+ { _id: send._id },
1871
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
1872
+ );
1873
+ return;
1874
+ }
1875
+ let renderCtx;
1876
+ let rendered;
1877
+ try {
1878
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
1879
+ reason: "send",
1880
+ templateSlug: template.slug,
1881
+ flowSlug: run?.flowSlug,
1882
+ eventName: run?.triggerEvent?.name,
1883
+ eventProperties: run?.triggerEvent?.properties
1884
+ });
1885
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
1886
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1887
+ } catch (err) {
1888
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
1889
+ throw err;
1890
+ }
1516
1891
  const tracking = applyTracking(rendered.html, {
1517
1892
  sendId: String(send._id),
1518
1893
  publicUrl: ctx.config.publicUrl,
@@ -1564,13 +1939,13 @@ async function dispatchSend(sendId, ctx) {
1564
1939
  }
1565
1940
  }
1566
1941
  );
1567
- await recordHealthCounter(ctx, "sent");
1942
+ await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
1568
1943
  } catch (err) {
1569
1944
  await ctx.collections.sends.updateOne(
1570
1945
  { _id: send._id },
1571
1946
  { $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
1572
1947
  );
1573
- await recordHealthCounter(ctx, "failedToSend");
1948
+ await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
1574
1949
  if (ctx.config.onSendFailure) {
1575
1950
  try {
1576
1951
  await ctx.config.onSendFailure({ send, error: err });
@@ -1588,7 +1963,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
1588
1963
  }
1589
1964
  return ctx.config.defaultProvider;
1590
1965
  }
1591
- function buildRenderContext(contact, run, vars, ctx) {
1966
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
1592
1967
  const scope = "marketing";
1593
1968
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
1594
1969
  const token = signUnsubscribeToken(
@@ -1597,8 +1972,10 @@ function buildRenderContext(contact, run, vars, ctx) {
1597
1972
  );
1598
1973
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
1599
1974
  return {
1975
+ ...resolved,
1600
1976
  contact,
1601
1977
  vars,
1978
+ event: run?.triggerEvent?.properties ?? {},
1602
1979
  unsubscribeUrl,
1603
1980
  senderAddress: ctx.config.senderAddress
1604
1981
  };
@@ -1683,8 +2060,15 @@ async function processOneRunStep(runId, ctx) {
1683
2060
  return handleCondition(run, step, contact, ctx);
1684
2061
  case "branch":
1685
2062
  return handleBranch(run, step, contact, ctx);
1686
- case "send":
2063
+ case "send": {
2064
+ if (step.delivery) {
2065
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
2066
+ if (deliverAt.getTime() > Date.now() + 3e4) {
2067
+ return deferSendForWindow(run, deliverAt, ctx);
2068
+ }
2069
+ }
1687
2070
  return handleSend(run, step, contact, flow, ctx);
2071
+ }
1688
2072
  case "tag":
1689
2073
  return handleTag(run, step, ctx);
1690
2074
  case "fire_event":
@@ -1835,6 +2219,34 @@ async function handleWebhookStep(run, step, ctx) {
1835
2219
  }
1836
2220
  }
1837
2221
  }
2222
+ async function deferSendForWindow(run, deliverAt, ctx) {
2223
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
2224
+ // Only write once per deferral — if nextActionAt already points at (or
2225
+ // past) the slot, another worker/tick got here first.
2226
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
2227
+ {
2228
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
2229
+ $push: {
2230
+ history: {
2231
+ stepIndex: run.currentStepIndex,
2232
+ action: "send_deferred",
2233
+ at: /* @__PURE__ */ new Date(),
2234
+ details: { until: deliverAt }
2235
+ }
2236
+ }
2237
+ },
2238
+ { returnDocument: "after" }
2239
+ );
2240
+ if (!updated) return;
2241
+ await ctx.queues.advance.add(
2242
+ "advance",
2243
+ { flowRunId: String(run._id) },
2244
+ {
2245
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
2246
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
2247
+ }
2248
+ );
2249
+ }
1838
2250
  async function advanceStep(run, ctx, log, opts = {}) {
1839
2251
  const stepInc = opts.stepInc ?? 1;
1840
2252
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -2154,7 +2566,15 @@ async function promoteSoftBounces(ctx) {
2154
2566
  const cutoff = new Date(Date.now() - windowDays * 864e5);
2155
2567
  const offenders = await ctx.collections.sends.aggregate([
2156
2568
  { $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
2157
- { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
2569
+ { $sort: { queuedAt: 1 } },
2570
+ {
2571
+ $group: {
2572
+ _id: "$emailAtSend",
2573
+ count: { $sum: 1 },
2574
+ lastFromEmail: { $last: "$fromEmail" },
2575
+ lastKind: { $last: "$kind" }
2576
+ }
2577
+ },
2158
2578
  { $match: { count: { $gte: threshold } } },
2159
2579
  { $limit: 200 }
2160
2580
  ]).toArray();
@@ -2186,81 +2606,984 @@ async function promoteSoftBounces(ctx) {
2186
2606
  { emailAtSubscribe: email },
2187
2607
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
2188
2608
  );
2189
- await recordHealthCounter(ctx, "hardBounced");
2609
+ await recordHealthCounter(
2610
+ ctx,
2611
+ "hardBounced",
2612
+ o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
2613
+ );
2190
2614
  }
2191
2615
  }
2192
-
2193
- // src/server/runner/tick.ts
2194
- var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
2195
- async function runTick(ctx) {
2196
- await ctx.collections.health.updateOne(
2197
- { _id: "singleton" },
2198
- {
2199
- $set: { updatedAt: /* @__PURE__ */ new Date() },
2200
- $setOnInsert: {
2201
- _id: "singleton",
2202
- windowStartedAt: /* @__PURE__ */ new Date(),
2203
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
2204
- status: "healthy",
2205
- trippedAt: null,
2206
- trippedReason: null,
2207
- manuallyResumedAt: null,
2208
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
2209
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
2616
+ var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
2617
+ var DNS_CONCURRENCY = 8;
2618
+ var defaultResolver = {
2619
+ resolve4: (hostname) => dns.resolve4(hostname)
2620
+ };
2621
+ async function runDnsblChecks(ctx, opts = {}) {
2622
+ const cfg = ctx.config.dnsbl ?? {};
2623
+ const intervalHours = cfg.intervalHours ?? 24;
2624
+ if (!opts.force && intervalHours <= 0) {
2625
+ return { ran: false, reason: "disabled" };
2626
+ }
2627
+ const targets = collectTargets(ctx, cfg);
2628
+ if (targets.domains.length === 0 && targets.ips.length === 0) {
2629
+ return { ran: false, reason: "no_targets" };
2630
+ }
2631
+ const resolver = opts.resolver ?? defaultResolver;
2632
+ const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
2633
+ const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
2634
+ const pairs = [];
2635
+ for (const d of targets.domains) {
2636
+ for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
2637
+ }
2638
+ for (const ip of targets.ips) {
2639
+ for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
2640
+ }
2641
+ const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
2642
+ let duePairs = pairs;
2643
+ if (throttleCutoff != null) {
2644
+ const existing = await ctx.collections.dnsblChecks.find(
2645
+ {
2646
+ $or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
2647
+ },
2648
+ { projection: { target: 1, list: 1, runAt: 1 } }
2649
+ ).toArray();
2650
+ const fresh = /* @__PURE__ */ new Set();
2651
+ for (const e of existing) {
2652
+ if (new Date(e.runAt).getTime() > throttleCutoff) {
2653
+ fresh.add(`${e.target}|${e.list}`);
2210
2654
  }
2211
- },
2212
- { upsert: true }
2213
- ).catch(() => {
2214
- });
2215
- await processNewlyFiredEventTriggers(ctx).catch((err) => {
2216
- console.error("mailery: triggers scan failed", err);
2217
- });
2218
- await sweepStrandedFlowRuns(ctx).catch((err) => {
2219
- console.error("mailery: sweep failed", err);
2220
- });
2221
- await sweepStrandedSends(ctx).catch((err) => {
2222
- console.error("mailery: stranded-send sweep failed", err);
2223
- });
2224
- await drainOutbox(ctx).catch((err) => {
2225
- console.error("mailery: outbox drain failed", err);
2226
- });
2227
- await processScheduledBroadcasts2(ctx).catch((err) => {
2228
- console.error("mailery: broadcast dispatch failed", err);
2229
- });
2230
- await evaluateHealth(ctx).catch((err) => {
2231
- console.error("mailery: health evaluation failed", err);
2232
- });
2233
- await promoteSoftBounces(ctx).catch((err) => {
2234
- console.error("mailery: soft-bounce promotion failed", err);
2235
- });
2236
- }
2237
- async function sweepStrandedSends(ctx) {
2238
- const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
2239
- const cursor = ctx.collections.sends.find(
2240
- { status: "sending", updatedAt: { $lt: cutoff } },
2241
- { projection: { _id: 1 } }
2242
- ).limit(500);
2243
- for await (const row of cursor) {
2244
- const reset = await ctx.collections.sends.updateOne(
2245
- { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
2246
- { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
2655
+ }
2656
+ duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
2657
+ if (duePairs.length === 0) {
2658
+ return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
2659
+ }
2660
+ }
2661
+ let listedCount = 0;
2662
+ async function processPair(p) {
2663
+ const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
2664
+ const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
2665
+ if (lookup.transient) return;
2666
+ if (lookup.result === "listed") listedCount++;
2667
+ await ctx.collections.dnsblChecks.updateOne(
2668
+ { target: p.target, list: p.list.host },
2669
+ {
2670
+ $set: {
2671
+ target: p.target,
2672
+ targetKind: p.targetKind,
2673
+ list: p.list.host,
2674
+ listLabel: p.list.label,
2675
+ result: lookup.result,
2676
+ returnCodes: lookup.returnCodes,
2677
+ errorMessage: lookup.errorMessage,
2678
+ runAt: /* @__PURE__ */ new Date()
2679
+ }
2680
+ },
2681
+ { upsert: true }
2247
2682
  );
2248
- if (reset.modifiedCount === 0) continue;
2249
- await ctx.queues.send.add("send", { sendId: String(row._id) }, {
2250
- attempts: ctx.config.sendRetryAttempts,
2251
- backoff: { type: "exponential", delay: 6e4 }
2252
- });
2253
2683
  }
2684
+ let idx = 0;
2685
+ await Promise.all(
2686
+ Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
2687
+ while (idx < duePairs.length) {
2688
+ const my = idx++;
2689
+ await processPair(duePairs[my]);
2690
+ }
2691
+ })
2692
+ );
2693
+ return { ran: true, totalChecks: duePairs.length, listedCount };
2254
2694
  }
2255
- async function drainOutbox(ctx) {
2256
- const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
2257
- for (const row of batch) {
2258
- try {
2259
- if (row.payload.type === "event") {
2260
- try {
2261
- await ctx.collections.events.insertOne({
2262
- externalId: row.payload.data.externalId,
2263
- name: row.payload.data.name,
2695
+ function collectTargets(ctx, cfg) {
2696
+ const domains = /* @__PURE__ */ new Set();
2697
+ const registry = ctx.config.senderDomains;
2698
+ if (registry) {
2699
+ for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
2700
+ }
2701
+ const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
2702
+ if (fromDomain) domains.add(fromDomain);
2703
+ const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
2704
+ if (txnDomain) domains.add(txnDomain);
2705
+ return {
2706
+ domains: Array.from(domains),
2707
+ ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
2708
+ };
2709
+ }
2710
+ function buildQueryName(target, kind, listHost) {
2711
+ if (kind === "ip") {
2712
+ const v = net.isIP(target);
2713
+ if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
2714
+ if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
2715
+ return null;
2716
+ }
2717
+ const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
2718
+ return `${domain}.${listHost}`;
2719
+ }
2720
+ var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
2721
+ async function queryDnsbl(resolver, query) {
2722
+ try {
2723
+ const records = await resolver.resolve4(query);
2724
+ return interpretRecords(records);
2725
+ } catch (err) {
2726
+ const code = err?.code;
2727
+ if (code === "ENOTFOUND" || code === "ENODATA") {
2728
+ return { result: "clean", returnCodes: [], errorMessage: null };
2729
+ }
2730
+ if (code && TRANSIENT_DNS_CODES.has(code)) {
2731
+ return {
2732
+ transient: true,
2733
+ result: "error",
2734
+ returnCodes: [],
2735
+ errorMessage: String(err?.message ?? err)
2736
+ };
2737
+ }
2738
+ return {
2739
+ result: "error",
2740
+ returnCodes: [],
2741
+ errorMessage: String(err?.message ?? err)
2742
+ };
2743
+ }
2744
+ }
2745
+ function interpretRecords(records) {
2746
+ if (!records || records.length === 0) {
2747
+ return { result: "clean", returnCodes: [], errorMessage: null };
2748
+ }
2749
+ const errorish = records.filter((r) => r.startsWith("127.255.255."));
2750
+ if (errorish.length === records.length) {
2751
+ return {
2752
+ result: "error",
2753
+ returnCodes: records,
2754
+ errorMessage: `list returned reserved code(s): ${records.join(", ")}`
2755
+ };
2756
+ }
2757
+ const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
2758
+ if (listed.length > 0) {
2759
+ return { result: "listed", returnCodes: records, errorMessage: null };
2760
+ }
2761
+ return { result: "clean", returnCodes: records, errorMessage: null };
2762
+ }
2763
+ function reverseIPv4(ip) {
2764
+ return ip.split(".").reverse().join(".");
2765
+ }
2766
+ function reverseIPv6Nibbles(ip) {
2767
+ const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
2768
+ let normalized = ip;
2769
+ if (v4Match) {
2770
+ const octets = v4Match[1].split(".").map((o) => Number(o));
2771
+ if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
2772
+ const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
2773
+ const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
2774
+ normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
2775
+ }
2776
+ }
2777
+ const sides = normalized.split("::");
2778
+ let groups;
2779
+ if (sides.length === 1) {
2780
+ groups = sides[0].split(":");
2781
+ } else {
2782
+ const left = sides[0].split(":").filter(Boolean);
2783
+ const right = sides[1].split(":").filter(Boolean);
2784
+ const missing = 8 - left.length - right.length;
2785
+ groups = [...left, ...Array(missing).fill("0"), ...right];
2786
+ }
2787
+ if (groups.length !== 8) {
2788
+ throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
2789
+ }
2790
+ const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
2791
+ return padded.split("").reverse().join(".");
2792
+ }
2793
+ function registrableDomain(domain) {
2794
+ const parsed = psl.parse(domain);
2795
+ if ("domain" in parsed && parsed.domain) return parsed.domain;
2796
+ return domain;
2797
+ }
2798
+
2799
+ // src/server/runner/postmaster.ts
2800
+ var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
2801
+ var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
2802
+ var FETCH_TIMEOUT_MS = 15e3;
2803
+ async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
2804
+ const ctl = new AbortController();
2805
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
2806
+ try {
2807
+ return await fetcher(url, { ...init, signal: ctl.signal });
2808
+ } finally {
2809
+ clearTimeout(t);
2810
+ }
2811
+ }
2812
+ function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
2813
+ let cached = null;
2814
+ async function getAccessToken() {
2815
+ if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
2816
+ return cached.accessToken;
2817
+ }
2818
+ const body = new URLSearchParams({
2819
+ client_id: cfg.clientId,
2820
+ client_secret: cfg.clientSecret,
2821
+ refresh_token: cfg.refreshToken,
2822
+ grant_type: "refresh_token"
2823
+ });
2824
+ const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
2825
+ method: "POST",
2826
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2827
+ body: body.toString()
2828
+ });
2829
+ if (!res.ok) {
2830
+ throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
2831
+ }
2832
+ const data = await res.json();
2833
+ cached = {
2834
+ accessToken: data.access_token,
2835
+ expiresAt: Date.now() + data.expires_in * 1e3
2836
+ };
2837
+ return cached.accessToken;
2838
+ }
2839
+ async function authedGet(path3) {
2840
+ const token = await getAccessToken();
2841
+ const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path3}`, {
2842
+ method: "GET",
2843
+ headers: { authorization: `Bearer ${token}` }
2844
+ });
2845
+ if (!res.ok) {
2846
+ throw new Error(`Postmaster GET ${path3} failed: ${res.status} ${await safeText(res)}`);
2847
+ }
2848
+ return await res.json();
2849
+ }
2850
+ return {
2851
+ async listDomains() {
2852
+ const data = await authedGet("/domains");
2853
+ return data.domains ?? [];
2854
+ },
2855
+ async getLatestTrafficStats(domain) {
2856
+ const data = await authedGet(
2857
+ `/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
2858
+ );
2859
+ return data.trafficStats?.[0] ?? null;
2860
+ }
2861
+ };
2862
+ }
2863
+ async function safeText(res) {
2864
+ try {
2865
+ return await res.text();
2866
+ } catch {
2867
+ return "<no body>";
2868
+ }
2869
+ }
2870
+ async function runPostmasterPull(ctx, opts = {}) {
2871
+ const cfg = ctx.config.postmaster;
2872
+ if (!cfg) return { ran: false, reason: "not_configured" };
2873
+ const intervalHours = cfg.intervalHours ?? 24;
2874
+ if (!opts.force && intervalHours <= 0) {
2875
+ return { ran: false, reason: "disabled" };
2876
+ }
2877
+ const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
2878
+ const allDomains = resolveDomains(ctx, cfg);
2879
+ if (allDomains.length === 0) {
2880
+ return { ran: false, reason: "no_domains" };
2881
+ }
2882
+ let domains = allDomains;
2883
+ if (!opts.force) {
2884
+ const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
2885
+ const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
2886
+ const lastByDomain = /* @__PURE__ */ new Map();
2887
+ for (const s of latest) {
2888
+ const ts = new Date(s.fetchedAt).getTime();
2889
+ const cur = lastByDomain.get(s.domain) ?? 0;
2890
+ if (ts > cur) lastByDomain.set(s.domain, ts);
2891
+ }
2892
+ domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
2893
+ if (domains.length === 0) {
2894
+ return { ran: false, reason: "not_due" };
2895
+ }
2896
+ }
2897
+ const fetches = await Promise.all(
2898
+ domains.map(async (domain) => {
2899
+ try {
2900
+ const stat = await client.getLatestTrafficStats(domain);
2901
+ return { domain, stat, error: null };
2902
+ } catch (err) {
2903
+ console.error(`mailery: postmaster fetch failed for ${domain}`, err);
2904
+ return { domain, stat: null, error: err };
2905
+ }
2906
+ })
2907
+ );
2908
+ let fetched = 0;
2909
+ const trippedDomains = [];
2910
+ for (const { domain, stat } of fetches) {
2911
+ if (!stat) continue;
2912
+ const snapshot = toSnapshot(domain, stat);
2913
+ if (!snapshot) continue;
2914
+ fetched++;
2915
+ await ctx.collections.postmasterSnapshots.updateOne(
2916
+ { domain: snapshot.domain, date: snapshot.date },
2917
+ { $set: snapshot },
2918
+ { upsert: true }
2919
+ );
2920
+ if (snapshot.domainReputation === "BAD") {
2921
+ const kindsToTrip = kindsForDomain(ctx, domain);
2922
+ for (const kind of kindsToTrip) {
2923
+ const tripped = await tripBucket(ctx, domain, kind, snapshot);
2924
+ if (tripped) trippedDomains.push(`${domain}|${kind}`);
2925
+ }
2926
+ }
2927
+ }
2928
+ return { ran: true, fetched, trippedDomains };
2929
+ }
2930
+ function kindsForDomain(ctx, domain) {
2931
+ const registry = ctx.config.senderDomains ?? {};
2932
+ const entry = registry[domain.toLowerCase()];
2933
+ if (!entry) return [];
2934
+ if (entry.kind === "both") return ["marketing", "transactional"];
2935
+ return [entry.kind];
2936
+ }
2937
+ function resolveDomains(ctx, cfg) {
2938
+ if (cfg.domains && cfg.domains.length > 0) {
2939
+ return cfg.domains.map((d) => d.toLowerCase());
2940
+ }
2941
+ const out = /* @__PURE__ */ new Set();
2942
+ const registry = ctx.config.senderDomains;
2943
+ if (registry) {
2944
+ for (const d of Object.keys(registry)) out.add(d.toLowerCase());
2945
+ }
2946
+ const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
2947
+ if (f) out.add(f);
2948
+ const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
2949
+ if (t) out.add(t);
2950
+ return Array.from(out);
2951
+ }
2952
+ function toSnapshot(domain, stat) {
2953
+ const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
2954
+ if (!m) {
2955
+ console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
2956
+ return null;
2957
+ }
2958
+ const yyyymmdd = m[1];
2959
+ const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
2960
+ return {
2961
+ domain,
2962
+ date,
2963
+ domainReputation: stat.domainReputation ?? null,
2964
+ ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
2965
+ reputation: r.reputation,
2966
+ ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
2967
+ })) : null,
2968
+ userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
2969
+ spfSuccessRatio: stat.spfSuccessRatio ?? null,
2970
+ dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
2971
+ dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
2972
+ outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
2973
+ inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
2974
+ deliveryErrors: stat.deliveryErrors ?? null,
2975
+ spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
2976
+ fetchedAt: /* @__PURE__ */ new Date()
2977
+ };
2978
+ }
2979
+ async function tripBucket(ctx, domain, kind, snapshot) {
2980
+ const id = healthBucketId(domain, kind);
2981
+ const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
2982
+ const prior = await ctx.collections.health.findOne({ _id: id });
2983
+ if (prior?.status === "tripped") {
2984
+ return false;
2985
+ }
2986
+ await ctx.collections.health.updateOne(
2987
+ { _id: id },
2988
+ {
2989
+ $set: {
2990
+ status: "tripped",
2991
+ trippedAt: /* @__PURE__ */ new Date(),
2992
+ trippedReason: reason,
2993
+ updatedAt: /* @__PURE__ */ new Date()
2994
+ },
2995
+ $setOnInsert: {
2996
+ _id: id,
2997
+ senderDomain: domain,
2998
+ kind,
2999
+ windowStartedAt: /* @__PURE__ */ new Date(),
3000
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
3001
+ manuallyResumedAt: null,
3002
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3003
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3004
+ }
3005
+ },
3006
+ { upsert: true }
3007
+ );
3008
+ if (ctx.config.onCircuitBreakerTrip) {
3009
+ try {
3010
+ await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
3011
+ } catch {
3012
+ }
3013
+ }
3014
+ return true;
3015
+ }
3016
+
3017
+ // src/server/runner/snds.ts
3018
+ var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
3019
+ var FETCH_TIMEOUT_MS2 = 3e4;
3020
+ async function runSndsPull(ctx, opts = {}) {
3021
+ const cfg = ctx.config.snds;
3022
+ if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
3023
+ const intervalHours = cfg.intervalHours ?? 24;
3024
+ if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
3025
+ if (!opts.force) {
3026
+ const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
3027
+ if (latest[0]) {
3028
+ const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
3029
+ if (ageMs < intervalHours * 60 * 60 * 1e3) {
3030
+ return { ran: false, reason: "not_due" };
3031
+ }
3032
+ }
3033
+ }
3034
+ const fetcher = opts.fetcher ?? globalThis.fetch;
3035
+ const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
3036
+ const ctl = new AbortController();
3037
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
3038
+ let res;
3039
+ try {
3040
+ res = await fetcher(url, { method: "GET", signal: ctl.signal });
3041
+ } catch (err) {
3042
+ throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
3043
+ } finally {
3044
+ clearTimeout(t);
3045
+ }
3046
+ if (!res.ok) {
3047
+ throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
3048
+ }
3049
+ const csv = await res.text();
3050
+ const rows = parseSndsCsv(csv);
3051
+ const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
3052
+ const now = /* @__PURE__ */ new Date();
3053
+ const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
3054
+ updateOne: {
3055
+ filter: { ip: row.ip, activityStart: row.activityStart },
3056
+ update: { $set: { ...row, fetchedAt: now } },
3057
+ upsert: true
3058
+ }
3059
+ }));
3060
+ let persisted = 0;
3061
+ if (ops.length > 0) {
3062
+ const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
3063
+ persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
3064
+ }
3065
+ return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
3066
+ }
3067
+ function redactKey(s, key) {
3068
+ if (!key) return s;
3069
+ return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
3070
+ }
3071
+ async function safeText2(res) {
3072
+ try {
3073
+ return await res.text();
3074
+ } catch {
3075
+ return "<no body>";
3076
+ }
3077
+ }
3078
+ function parseSndsCsv(csv) {
3079
+ const out = [];
3080
+ for (const raw of csv.split(/\r?\n/)) {
3081
+ if (!raw.trim()) continue;
3082
+ const fields = splitCsvRow(raw);
3083
+ if (fields.length < 7) continue;
3084
+ const parsed = parseRow(fields);
3085
+ if (parsed) out.push(parsed);
3086
+ }
3087
+ return out;
3088
+ }
3089
+ function splitCsvRow(row) {
3090
+ const out = [];
3091
+ let cur = "";
3092
+ let inQuotes = false;
3093
+ for (let i = 0; i < row.length; i++) {
3094
+ const ch = row[i];
3095
+ if (inQuotes) {
3096
+ if (ch === '"') {
3097
+ if (row[i + 1] === '"') {
3098
+ cur += '"';
3099
+ i++;
3100
+ } else {
3101
+ inQuotes = false;
3102
+ }
3103
+ } else {
3104
+ cur += ch;
3105
+ }
3106
+ } else if (ch === '"') {
3107
+ inQuotes = true;
3108
+ } else if (ch === ",") {
3109
+ out.push(cur);
3110
+ cur = "";
3111
+ } else {
3112
+ cur += ch;
3113
+ }
3114
+ }
3115
+ out.push(cur);
3116
+ return out.map((f) => f.trim());
3117
+ }
3118
+ function parseRow(fields) {
3119
+ const [
3120
+ ip,
3121
+ activityStart,
3122
+ activityEnd,
3123
+ rcptCommands,
3124
+ dataCommands,
3125
+ messageRecipients,
3126
+ filterResult,
3127
+ complaintRate,
3128
+ trapMessageCount,
3129
+ sampleHelo,
3130
+ sampleMailFrom
3131
+ ] = fields;
3132
+ const start = parseSndsDate(activityStart ?? "");
3133
+ const end = parseSndsDate(activityEnd ?? "");
3134
+ if (!ip || !start || !end) return null;
3135
+ return {
3136
+ ip,
3137
+ activityStart: start,
3138
+ activityEnd: end,
3139
+ rcptCommands: toInt(rcptCommands),
3140
+ dataCommands: toInt(dataCommands),
3141
+ messageRecipients: toInt(messageRecipients),
3142
+ filterResult: normalizeFilter(filterResult ?? ""),
3143
+ complaintRate: parseComplaintRate(complaintRate ?? ""),
3144
+ trapMessageCount: toInt(trapMessageCount),
3145
+ sampleHelo: sampleHelo ? sampleHelo : null,
3146
+ sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
3147
+ };
3148
+ }
3149
+ function toInt(s) {
3150
+ if (!s) return 0;
3151
+ const n = Number(s.replace(/[^\d-]/g, ""));
3152
+ return Number.isFinite(n) ? n : 0;
3153
+ }
3154
+ function normalizeFilter(s) {
3155
+ const u = s.toUpperCase().trim();
3156
+ if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
3157
+ return "UNKNOWN";
3158
+ }
3159
+ function parseComplaintRate(s) {
3160
+ const trimmed = s.trim();
3161
+ if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
3162
+ const cleaned = trimmed.replace(/%/g, "");
3163
+ let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
3164
+ if (m) return Number(m[1]) / 100;
3165
+ m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
3166
+ if (m) return Number(m[1]) / 100;
3167
+ m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
3168
+ if (m) return Number(m[2]) / 100;
3169
+ m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
3170
+ if (m) return Number(m[1]) / 100;
3171
+ return null;
3172
+ }
3173
+ function parseSndsDate(s) {
3174
+ const trimmed = s.trim();
3175
+ if (!trimmed) return null;
3176
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
3177
+ if (m) {
3178
+ const month = Number(m[1]) - 1;
3179
+ const day = Number(m[2]);
3180
+ const year = Number(m[3]);
3181
+ let hour = Number(m[4]);
3182
+ const minute = Number(m[5]);
3183
+ const second = Number(m[6] ?? 0);
3184
+ const meridiem = m[7]?.toUpperCase();
3185
+ if (meridiem === "PM" && hour < 12) hour += 12;
3186
+ if (meridiem === "AM" && hour === 12) hour = 0;
3187
+ const wallMs = Date.UTC(year, month, day, hour, minute, second);
3188
+ if (!Number.isFinite(wallMs)) return null;
3189
+ const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
3190
+ return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
3191
+ }
3192
+ const d = new Date(trimmed);
3193
+ if (!isNaN(d.getTime())) return d;
3194
+ return null;
3195
+ }
3196
+ function isUsPacificDst(year, month0, day) {
3197
+ if (month0 > 2 && month0 < 10) return true;
3198
+ if (month0 < 2 || month0 > 10) return false;
3199
+ if (month0 === 2) {
3200
+ const dst2 = nthSundayOfMonth(year, 2, 2);
3201
+ return day >= dst2;
3202
+ }
3203
+ const dst = nthSundayOfMonth(year, 10, 1);
3204
+ return day < dst;
3205
+ }
3206
+ function nthSundayOfMonth(year, month0, n) {
3207
+ const first = new Date(Date.UTC(year, month0, 1));
3208
+ const dayOfWeek = first.getUTCDay();
3209
+ const firstSunday = 1 + (7 - dayOfWeek) % 7;
3210
+ return firstSunday + (n - 1) * 7;
3211
+ }
3212
+
3213
+ // src/server/runner/dmarc.ts
3214
+ var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
3215
+ function assertSafeZipEntryName(rawName) {
3216
+ if (typeof rawName !== "string" || rawName.length === 0) {
3217
+ throw new Error(`zip entry has unsafe path: <empty>`);
3218
+ }
3219
+ if (rawName.includes("\0")) {
3220
+ throw new Error(`zip entry has unsafe path: contains null byte`);
3221
+ }
3222
+ if (rawName.startsWith("/") || rawName.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(rawName)) {
3223
+ throw new Error(`zip entry has unsafe path: ${rawName}`);
3224
+ }
3225
+ const parts = rawName.split(/[/\\]/);
3226
+ if (parts.some((p) => p === "..")) {
3227
+ throw new Error(`zip entry has unsafe path: ${rawName}`);
3228
+ }
3229
+ }
3230
+ async function extractDmarcXmls(buffer, filename) {
3231
+ const lower = (filename ?? "").toLowerCase();
3232
+ const zlib = await import('zlib');
3233
+ if (lower.endsWith(".gz")) {
3234
+ const xml = await new Promise((resolve, reject) => {
3235
+ const chunks = [];
3236
+ let bytes = 0;
3237
+ const stream = zlib.createGunzip();
3238
+ stream.on("data", (chunk) => {
3239
+ bytes += chunk.length;
3240
+ if (bytes > MAX_DECOMPRESSED_BYTES) {
3241
+ stream.destroy();
3242
+ reject(new Error(`DMARC gzip exceeds ${MAX_DECOMPRESSED_BYTES} bytes (decompression bomb?)`));
3243
+ return;
3244
+ }
3245
+ chunks.push(chunk);
3246
+ });
3247
+ stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
3248
+ stream.on("error", reject);
3249
+ stream.end(buffer);
3250
+ });
3251
+ return [xml];
3252
+ }
3253
+ if (lower.endsWith(".zip")) {
3254
+ const { default: AdmZip } = await import('adm-zip');
3255
+ const zip = new AdmZip(buffer);
3256
+ const entries = zip.getEntries().filter((e) => !e.isDirectory);
3257
+ if (entries.length === 0) throw new Error("zip contains no files");
3258
+ let total = 0;
3259
+ const pieces = [];
3260
+ for (const entry of entries) {
3261
+ assertSafeZipEntryName(entry.entryName);
3262
+ const declaredSize = entry.header?.size;
3263
+ if (typeof declaredSize !== "number" || declaredSize <= 0) {
3264
+ throw new Error(`zip entry ${entry.entryName} has no declared size (refusing to decompress)`);
3265
+ }
3266
+ if (declaredSize > MAX_DECOMPRESSED_BYTES || total + declaredSize > MAX_DECOMPRESSED_BYTES) {
3267
+ throw new Error(`DMARC zip exceeds ${MAX_DECOMPRESSED_BYTES} bytes (decompression bomb?)`);
3268
+ }
3269
+ const data = entry.getData();
3270
+ total += data.length;
3271
+ if (total > MAX_DECOMPRESSED_BYTES) {
3272
+ throw new Error(`DMARC zip decompressed payload exceeds ${MAX_DECOMPRESSED_BYTES} bytes (forged header?)`);
3273
+ }
3274
+ if (declaredSize > 10 * 1024 && data.length > declaredSize * 64 + 1024) {
3275
+ throw new Error(`DMARC zip entry ${entry.entryName} decompressed size (${data.length}) far exceeds declared (${declaredSize})`);
3276
+ }
3277
+ pieces.push(data.toString("utf8"));
3278
+ }
3279
+ return pieces;
3280
+ }
3281
+ if (buffer.length > MAX_DECOMPRESSED_BYTES) {
3282
+ throw new Error(`DMARC payload exceeds ${MAX_DECOMPRESSED_BYTES} bytes`);
3283
+ }
3284
+ return [buffer.toString("utf8")];
3285
+ }
3286
+ function parseDmarcReport(xml) {
3287
+ const { XMLParser } = __require("fast-xml-parser");
3288
+ const parser = new XMLParser({
3289
+ ignoreAttributes: false,
3290
+ parseAttributeValue: false,
3291
+ parseTagValue: false,
3292
+ trimValues: true,
3293
+ // RFC 7489 specifies lowercase snake_case tag names, but some
3294
+ // less-careful receivers emit CamelCase. Normalize before lookup.
3295
+ transformTagName: (t) => t.toLowerCase()
3296
+ });
3297
+ const root = parser.parse(xml);
3298
+ const fb = root?.feedback;
3299
+ if (!fb) throw new Error("DMARC XML missing <feedback> root");
3300
+ const meta = fb.report_metadata ?? {};
3301
+ const policy = fb.policy_published ?? {};
3302
+ const reportId = String(meta.report_id ?? "").trim();
3303
+ const orgName = String(meta.org_name ?? "").trim();
3304
+ const email = String(meta.email ?? "").trim();
3305
+ const domain = String(policy.domain ?? "").trim().toLowerCase();
3306
+ const policyP = policy.p ?? "none";
3307
+ const rawPct = Number(policy.pct ?? 100);
3308
+ const policyPct = Number.isFinite(rawPct) ? Math.max(0, Math.min(100, rawPct)) : 100;
3309
+ if (!reportId) throw new Error("DMARC XML missing report_id");
3310
+ if (!domain) throw new Error("DMARC XML missing policy_published.domain");
3311
+ const range = meta.date_range ?? {};
3312
+ const begin = secondsToDate(range.begin);
3313
+ const end = secondsToDate(range.end);
3314
+ if (!begin || !end) throw new Error("DMARC XML missing date_range");
3315
+ const records = toArray(fb.record);
3316
+ const failures = [];
3317
+ let totalMessages = 0;
3318
+ let passCount = 0;
3319
+ let failCount = 0;
3320
+ const midMs = Math.floor((begin.getTime() + end.getTime()) / 2);
3321
+ const day = new Date(midMs).toISOString().slice(0, 10);
3322
+ for (const rec of records) {
3323
+ const row = rec?.row ?? {};
3324
+ const count = Number(row.count ?? 0) || 0;
3325
+ totalMessages += count;
3326
+ const evald = row.policy_evaluated ?? {};
3327
+ const dkim = evald.dkim ?? "none";
3328
+ const spf = evald.spf ?? "none";
3329
+ const aligned = dkim === "pass" || spf === "pass";
3330
+ if (aligned) {
3331
+ passCount += count;
3332
+ continue;
3333
+ }
3334
+ failCount += count;
3335
+ const sourceIp = String(row.source_ip ?? "").trim();
3336
+ if (!sourceIp) continue;
3337
+ const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
3338
+ const dispositionApplied = String(evald.disposition ?? "none");
3339
+ failures.push({
3340
+ reportId,
3341
+ domain,
3342
+ sourceIp,
3343
+ count,
3344
+ headerFrom,
3345
+ dkimResult: dkim,
3346
+ spfResult: spf,
3347
+ dispositionApplied,
3348
+ day
3349
+ });
3350
+ }
3351
+ return {
3352
+ report: {
3353
+ reportId,
3354
+ orgName,
3355
+ email,
3356
+ domain,
3357
+ policyP,
3358
+ policyPct,
3359
+ rangeStart: begin,
3360
+ rangeEnd: end,
3361
+ totalMessages,
3362
+ passCount,
3363
+ failCount
3364
+ },
3365
+ failures
3366
+ };
3367
+ }
3368
+ function secondsToDate(v) {
3369
+ if (v === void 0 || v === null) return null;
3370
+ const n = typeof v === "number" ? v : Number(String(v));
3371
+ if (!Number.isFinite(n) || n <= 0) return null;
3372
+ return new Date(n * 1e3);
3373
+ }
3374
+ function toArray(v) {
3375
+ if (v === void 0 || v === null) return [];
3376
+ return Array.isArray(v) ? v : [v];
3377
+ }
3378
+ async function ingestDmarcAttachment(ctx, buffer, filename) {
3379
+ const xmls = await extractDmarcXmls(buffer, filename);
3380
+ if (xmls.length === 0) throw new Error("attachment contained no XML payload");
3381
+ const results = [];
3382
+ let firstError = null;
3383
+ for (const xml of xmls) {
3384
+ try {
3385
+ const parsed = parseDmarcReport(xml);
3386
+ results.push(await ingestParsedDmarcReport(ctx, parsed));
3387
+ } catch (err) {
3388
+ if (!firstError) firstError = err;
3389
+ }
3390
+ }
3391
+ if (results.length === 0) {
3392
+ throw firstError ?? new Error("no parseable DMARC reports in attachment");
3393
+ }
3394
+ return { ...results[0], additionalReports: results.length - 1 };
3395
+ }
3396
+ async function ingestParsedDmarcReport(ctx, parsed) {
3397
+ const now = /* @__PURE__ */ new Date();
3398
+ const { report, failures } = parsed;
3399
+ let duplicate = false;
3400
+ try {
3401
+ await ctx.collections.dmarcReports.insertOne({ ...report, receivedAt: now });
3402
+ } catch (err) {
3403
+ if (err?.code === 11e3) {
3404
+ duplicate = true;
3405
+ } else {
3406
+ throw err;
3407
+ }
3408
+ }
3409
+ if (!duplicate && failures.length > 0) {
3410
+ try {
3411
+ await ctx.collections.dmarcFailures.insertMany(
3412
+ failures.map((f) => ({ ...f, receivedAt: now })),
3413
+ { ordered: false }
3414
+ );
3415
+ } catch (err) {
3416
+ const writeErrors = err?.writeErrors;
3417
+ if (Array.isArray(writeErrors)) {
3418
+ const nonDup = writeErrors.find((w) => w?.code !== 11e3);
3419
+ if (nonDup) throw err;
3420
+ } else if (err?.code !== 11e3) {
3421
+ throw err;
3422
+ }
3423
+ }
3424
+ }
3425
+ return {
3426
+ ok: true,
3427
+ reportId: report.reportId,
3428
+ domain: report.domain,
3429
+ rangeStart: report.rangeStart,
3430
+ rangeEnd: report.rangeEnd,
3431
+ totalMessages: report.totalMessages,
3432
+ passCount: report.passCount,
3433
+ failCount: report.failCount,
3434
+ duplicate
3435
+ };
3436
+ }
3437
+ var _lastPruneAt = 0;
3438
+ var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
3439
+ async function pruneDmarcFailures(ctx, opts = {}) {
3440
+ const cfg = ctx.config.dmarc;
3441
+ const days = cfg?.retentionDays ?? 90;
3442
+ if (days <= 0) return 0;
3443
+ if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
3444
+ _lastPruneAt = Date.now();
3445
+ const cutoff = new Date(Date.now() - days * 864e5);
3446
+ const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
3447
+ return r.deletedCount ?? 0;
3448
+ }
3449
+ async function resolveSourceTags(ctx) {
3450
+ const out = /* @__PURE__ */ new Map();
3451
+ for (const t of ctx.config.dmarc?.knownSources ?? []) {
3452
+ out.set(t.ip, { ip: t.ip, label: t.label, ignored: !!t.ignored, source: "config" });
3453
+ }
3454
+ const dbTags = await ctx.collections.dmarcSourceTags.find({}).toArray();
3455
+ for (const t of dbTags) {
3456
+ out.set(t.ip, { ip: t.ip, label: t.label, ignored: t.ignored, source: "db" });
3457
+ }
3458
+ return out;
3459
+ }
3460
+ function suggestPolicyProgression(input) {
3461
+ const { reports, failures, knownSourceIps, ignoredSourceIps, currentPolicy, currentPct } = input;
3462
+ if (currentPolicy === "reject") return null;
3463
+ const since30 = Date.now() - 30 * 864e5;
3464
+ const recentReports = reports.filter((r) => r.rangeEnd.getTime() >= since30);
3465
+ const totalMsgs = recentReports.reduce((acc, r) => acc + r.passCount + r.failCount, 0);
3466
+ const totalPass = recentReports.reduce((acc, r) => acc + r.passCount, 0);
3467
+ if (recentReports.length < 30) return null;
3468
+ if (totalMsgs < 1e3) return null;
3469
+ const untaggedFailures = failures.filter(
3470
+ (f) => f.receivedAt.getTime() >= since30 && !knownSourceIps.has(f.sourceIp) && !ignoredSourceIps.has(f.sourceIp)
3471
+ );
3472
+ if (untaggedFailures.length > 0) return null;
3473
+ const alignmentRate = totalMsgs === 0 ? 0 : totalPass / totalMsgs;
3474
+ if (currentPolicy === null || currentPolicy === "none") {
3475
+ if (alignmentRate >= 0.99) {
3476
+ return {
3477
+ policy: "quarantine",
3478
+ pct: 10,
3479
+ reason: `${(alignmentRate * 100).toFixed(2)}% alignment over ${recentReports.length} reports / ${totalMsgs.toLocaleString()} messages, all known sources tagged.`
3480
+ };
3481
+ }
3482
+ return null;
3483
+ }
3484
+ if (currentPolicy === "quarantine") {
3485
+ const pct = currentPct ?? 100;
3486
+ if (alignmentRate < 0.995) return null;
3487
+ if (pct < 25) return { policy: "quarantine", pct: 25, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct} to 25.` };
3488
+ if (pct < 50) return { policy: "quarantine", pct: 50, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct} to 50.` };
3489
+ if (pct < 100) return { policy: "quarantine", pct: 100, reason: `Alignment held at ${(alignmentRate * 100).toFixed(2)}% \u2014 safe to ramp pct from ${pct} to 100.` };
3490
+ if (alignmentRate >= 0.999) {
3491
+ return { policy: "reject", pct: 100, reason: `Alignment at ${(alignmentRate * 100).toFixed(3)}% with policy=quarantine pct=100 \u2014 safe to move to p=reject.` };
3492
+ }
3493
+ }
3494
+ return null;
3495
+ }
3496
+
3497
+ // src/server/runner/tick.ts
3498
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
3499
+ async function runTick(ctx) {
3500
+ try {
3501
+ await ctx.collections.health.updateOne(
3502
+ { _id: HEALTH_AGG_ID },
3503
+ {
3504
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
3505
+ $setOnInsert: {
3506
+ _id: HEALTH_AGG_ID,
3507
+ senderDomain: null,
3508
+ kind: null,
3509
+ windowStartedAt: /* @__PURE__ */ new Date(),
3510
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
3511
+ status: "healthy",
3512
+ trippedAt: null,
3513
+ trippedReason: null,
3514
+ manuallyResumedAt: null,
3515
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3516
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3517
+ }
3518
+ },
3519
+ { upsert: true }
3520
+ );
3521
+ } catch (err) {
3522
+ console.error("mailery: heartbeat write failed", err);
3523
+ }
3524
+ await processNewlyFiredEventTriggers(ctx).catch((err) => {
3525
+ console.error("mailery: triggers scan failed", err);
3526
+ });
3527
+ await sweepStrandedFlowRuns(ctx).catch((err) => {
3528
+ console.error("mailery: sweep failed", err);
3529
+ });
3530
+ await sweepStrandedSends(ctx).catch((err) => {
3531
+ console.error("mailery: stranded-send sweep failed", err);
3532
+ });
3533
+ await drainOutbox(ctx).catch((err) => {
3534
+ console.error("mailery: outbox drain failed", err);
3535
+ });
3536
+ await processScheduledBroadcasts2(ctx).catch((err) => {
3537
+ console.error("mailery: broadcast dispatch failed", err);
3538
+ });
3539
+ await evaluateHealth(ctx).catch((err) => {
3540
+ console.error("mailery: health evaluation failed", err);
3541
+ });
3542
+ await promoteSoftBounces(ctx).catch((err) => {
3543
+ console.error("mailery: soft-bounce promotion failed", err);
3544
+ });
3545
+ await Promise.all([
3546
+ runDnsblChecks(ctx).catch((err) => {
3547
+ console.error("mailery: dnsbl checks failed", err);
3548
+ }),
3549
+ runPostmasterPull(ctx).catch((err) => {
3550
+ console.error("mailery: postmaster pull failed", err);
3551
+ }),
3552
+ runSndsPull(ctx).catch((err) => {
3553
+ console.error("mailery: snds pull failed", err);
3554
+ }),
3555
+ pruneDmarcFailures(ctx).catch((err) => {
3556
+ console.error("mailery: dmarc prune failed", err);
3557
+ })
3558
+ ]);
3559
+ }
3560
+ async function sweepStrandedSends(ctx) {
3561
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
3562
+ const cursor = ctx.collections.sends.find(
3563
+ { status: "sending", updatedAt: { $lt: cutoff } },
3564
+ { projection: { _id: 1 } }
3565
+ ).limit(500);
3566
+ for await (const row of cursor) {
3567
+ const reset = await ctx.collections.sends.updateOne(
3568
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
3569
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
3570
+ );
3571
+ if (reset.modifiedCount === 0) continue;
3572
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
3573
+ attempts: ctx.config.sendRetryAttempts,
3574
+ backoff: { type: "exponential", delay: 6e4 }
3575
+ });
3576
+ }
3577
+ }
3578
+ async function drainOutbox(ctx) {
3579
+ const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
3580
+ for (const row of batch) {
3581
+ try {
3582
+ if (row.payload.type === "event") {
3583
+ try {
3584
+ await ctx.collections.events.insertOne({
3585
+ externalId: row.payload.data.externalId,
3586
+ name: row.payload.data.name,
2264
3587
  properties: row.payload.data.properties ?? {},
2265
3588
  dedupeKey: row.payload.dedupeKey,
2266
3589
  occurredAt: row.payload.data.occurredAt ?? /* @__PURE__ */ new Date(),
@@ -2290,6 +3613,10 @@ async function processScheduledBroadcasts2(ctx) {
2290
3613
  }
2291
3614
 
2292
3615
  // src/server/runner/webhook.ts
3616
+ function dimsFromSend(send) {
3617
+ if (!send) return null;
3618
+ return { fromEmail: send.fromEmail, kind: send.kind };
3619
+ }
2293
3620
  async function applyWebhookEvent(event, ctx) {
2294
3621
  const send = await ctx.collections.sends.findOne(
2295
3622
  event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
@@ -2303,7 +3630,7 @@ async function applyWebhookEvent(event, ctx) {
2303
3630
  { $set: { status: "delivered", deliveredAt: event.occurredAt } }
2304
3631
  );
2305
3632
  }
2306
- await recordHealthCounter(ctx, "delivered");
3633
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
2307
3634
  break;
2308
3635
  case "open":
2309
3636
  if (send) {
@@ -2358,8 +3685,11 @@ async function applyWebhookEvent(event, ctx) {
2358
3685
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
2359
3686
  );
2360
3687
  }
2361
- await recordHealthCounter(ctx, "bounced");
2362
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
3688
+ {
3689
+ const dims = dimsFromSend(send);
3690
+ await recordHealthCounter(ctx, "bounced", dims);
3691
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
3692
+ }
2363
3693
  break;
2364
3694
  }
2365
3695
  case "complaint":
@@ -2375,7 +3705,7 @@ async function applyWebhookEvent(event, ctx) {
2375
3705
  { emailAtSubscribe: event.email },
2376
3706
  { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
2377
3707
  );
2378
- await recordHealthCounter(ctx, "complained");
3708
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
2379
3709
  break;
2380
3710
  case "unsubscribe":
2381
3711
  if (send) {
@@ -2441,10 +3771,12 @@ var Mailer = class _Mailer {
2441
3771
  db: this.db,
2442
3772
  collections: this.collections,
2443
3773
  adapter: this.adapter,
3774
+ varsAdapter: this.config.varsAdapter,
2444
3775
  providers: this.providers,
2445
3776
  queues: this.queues,
2446
3777
  config: this.config,
2447
- handlebarsHelpers: this.config.handlebarsHelpers
3778
+ handlebarsHelpers: this.config.handlebarsHelpers,
3779
+ audit: (entry) => this.audit(entry)
2448
3780
  };
2449
3781
  }
2450
3782
  /**
@@ -2519,6 +3851,10 @@ var Mailer = class _Mailer {
2519
3851
  if (!config.providers[config.defaultProvider]) {
2520
3852
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
2521
3853
  }
3854
+ if (config.varsAdapter) {
3855
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
3856
+ assertNoReservedVarKeys2(config.varsAdapter);
3857
+ }
2522
3858
  const collections = getCollections(config.db, config.collectionPrefix);
2523
3859
  await ensureIndexes(config.db, config.collectionPrefix);
2524
3860
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -2726,13 +4062,79 @@ var Mailer = class _Mailer {
2726
4062
  );
2727
4063
  }
2728
4064
  }
2729
- async untag(externalId, tag) {
2730
- const parsed = tagInputSchema.parse({ externalId, tag });
2731
- if (this.adapter.removeTags) {
2732
- await this.adapter.removeTags(parsed.externalId, [parsed.tag]);
2733
- } else {
2734
- await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
4065
+ async untag(externalId, tag) {
4066
+ const parsed = tagInputSchema.parse({ externalId, tag });
4067
+ if (this.adapter.removeTags) {
4068
+ await this.adapter.removeTags(parsed.externalId, [parsed.tag]);
4069
+ } else {
4070
+ await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
4071
+ }
4072
+ }
4073
+ // -------------------------------------------------------------------------
4074
+ // Flow abort
4075
+ // -------------------------------------------------------------------------
4076
+ /**
4077
+ * Abort every active run of one flow for a contact, immediately. Runs parked
4078
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
4079
+ * no-op. Also cancels any of the flow's emails still sitting in the send
4080
+ * queue for this contact (queued or awaiting retry), so an abort means no
4081
+ * further mail, not just no further steps.
4082
+ *
4083
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
4084
+ * same handler that processes the business event ("user upgraded").
4085
+ */
4086
+ async abortFlow(flowSlug, externalId, opts = {}) {
4087
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
4088
+ const flow = await this.collections.flows.findOne(
4089
+ { slug: parsed.flowSlug },
4090
+ { projection: { _id: 1 } }
4091
+ );
4092
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
4093
+ const result = await this.abortActiveRuns(
4094
+ { externalId: parsed.externalId, flowId: flow._id },
4095
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4096
+ );
4097
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4098
+ await this.audit({
4099
+ actor: "host",
4100
+ action: "flow.abort",
4101
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
4102
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4103
+ });
4104
+ }
4105
+ return result;
4106
+ }
4107
+ /**
4108
+ * Abort every active flow run for a contact across all flows. Same semantics
4109
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
4110
+ */
4111
+ async abortAllFlows(externalId, opts = {}) {
4112
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
4113
+ const result = await this.abortActiveRuns(
4114
+ { externalId: parsed.externalId },
4115
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4116
+ );
4117
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4118
+ await this.audit({
4119
+ actor: "host",
4120
+ action: "flow.abort_all",
4121
+ resource: { collection: "mailer_flow_runs" },
4122
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4123
+ });
4124
+ }
4125
+ return result;
4126
+ }
4127
+ async abortActiveRuns(filter, exitReason) {
4128
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
4129
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
4130
+ for (const run of runs) {
4131
+ await exitFlowRun(run, exitReason, this.runnerContext);
2735
4132
  }
4133
+ const cancelled = await this.collections.sends.updateMany(
4134
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
4135
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
4136
+ );
4137
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
2736
4138
  }
2737
4139
  /**
2738
4140
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
@@ -2934,45 +4336,9 @@ var Mailer = class _Mailer {
2934
4336
  }
2935
4337
  };
2936
4338
 
2937
- // src/server/templates/sender-domain.ts
2938
- function validateSenderDomain(fromEmail, templateKind, registry) {
2939
- if (!registry || Object.keys(registry).length === 0) return { ok: true };
2940
- const domain = extractDomain(fromEmail);
2941
- if (!domain) {
2942
- return {
2943
- ok: false,
2944
- code: "invalid_email",
2945
- reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
2946
- };
2947
- }
2948
- const entry = registry[domain];
2949
- if (!entry) {
2950
- const known = Object.keys(registry).join(", ");
2951
- return {
2952
- ok: false,
2953
- code: "unregistered_domain",
2954
- reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
2955
- };
2956
- }
2957
- if (entry.kind === "both") return { ok: true };
2958
- if (entry.kind !== templateKind) {
2959
- return {
2960
- ok: false,
2961
- code: "wrong_kind",
2962
- reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
2963
- };
2964
- }
2965
- return { ok: true };
2966
- }
2967
- function extractDomain(email) {
2968
- if (typeof email !== "string") return null;
2969
- const at = email.lastIndexOf("@");
2970
- if (at <= 0 || at === email.length - 1) return null;
2971
- return email.slice(at + 1).toLowerCase().trim();
2972
- }
2973
-
2974
4339
  // src/server/index.ts
2975
4340
  init_mongo();
4341
+ init_vars();
2976
4342
 
2977
4343
  // src/server/providers/null.ts
2978
4344
  var counter = 0;
@@ -3001,6 +4367,254 @@ var NullProvider = class {
3001
4367
  // src/server/index.ts
3002
4368
  init_sendgrid();
3003
4369
 
4370
+ // src/server/templates/linter.ts
4371
+ var URL_SHORTENERS = ["bit.ly", "t.co", "tinyurl.com", "goo.gl", "ow.ly"];
4372
+ var SPAM_PHRASES = [
4373
+ { pattern: /\bFREE\b/, phrase: "FREE" },
4374
+ { pattern: /\bACT NOW\b/i, phrase: "ACT NOW" },
4375
+ { pattern: /\b100%\s+guaranteed\b/i, phrase: "100% guaranteed" }
4376
+ ];
4377
+ var MARKETING_UNSUBSCRIBE_TAG = /\{\{\s*unsubscribeUrl\s*\}\}/;
4378
+ function lintTemplate(rawInput, config = {}) {
4379
+ const input = {
4380
+ subject: typeof rawInput.subject === "string" ? rawInput.subject : "",
4381
+ preheader: typeof rawInput.preheader === "string" ? rawInput.preheader : "",
4382
+ mjml: typeof rawInput.mjml === "string" ? rawInput.mjml : "",
4383
+ editorJson: rawInput.editorJson,
4384
+ html: typeof rawInput.html === "string" ? rawInput.html : "",
4385
+ plainText: typeof rawInput.plainText === "string" ? rawInput.plainText : "",
4386
+ kind: rawInput.kind === "marketing" || rawInput.kind === "transactional" ? rawInput.kind : "transactional",
4387
+ fromEmail: typeof rawInput.fromEmail === "string" ? rawInput.fromEmail : ""
4388
+ };
4389
+ const issues = [];
4390
+ if (!input.plainText || input.plainText.trim().length === 0) {
4391
+ issues.push({
4392
+ rule: "missing_plain_text",
4393
+ severity: "error",
4394
+ message: "Plain-text alternative is empty.",
4395
+ hint: "Email clients and spam filters use the plain-text part. Add visible text content (MJML mj-text blocks, or text content in the Maily editor) \u2014 do not strip plain-text to empty."
4396
+ });
4397
+ }
4398
+ const imageCount = countMatches(input.html, /<img\b/gi);
4399
+ const textLen = input.plainText.trim().length;
4400
+ if (imageCount >= 1 && textLen < 20) {
4401
+ issues.push({
4402
+ rule: "image_only_body",
4403
+ severity: "error",
4404
+ message: "Body is mostly images with little or no text.",
4405
+ hint: "Image-only emails are heavily filtered as spam. Add at least one paragraph of meaningful text."
4406
+ });
4407
+ }
4408
+ const shortenerLinks = findShortenerLinks(input.html);
4409
+ if (shortenerLinks.length > 0) {
4410
+ issues.push({
4411
+ rule: "url_shortener",
4412
+ severity: "error",
4413
+ message: `Link uses a URL shortener: ${shortenerLinks.join(", ")}.`,
4414
+ hint: "URL shorteners are strongly correlated with spam and are routinely blocked. Use a direct link to your domain."
4415
+ });
4416
+ }
4417
+ if (hasBareUrlInVisibleText(input.html)) {
4418
+ issues.push({
4419
+ rule: "bare_url",
4420
+ severity: "warning",
4421
+ message: "A URL appears in the body text without being wrapped in a link.",
4422
+ hint: "Wrap visible URLs in an anchor tag \u2014 bare URLs look like spam template residue and reduce engagement."
4423
+ });
4424
+ }
4425
+ const spam = findSpamSignals(`${input.subject}
4426
+ ${input.plainText}`);
4427
+ if (spam.length > 0) {
4428
+ issues.push({
4429
+ rule: "spam_phrases",
4430
+ severity: "warning",
4431
+ message: `Content contains spam-flagged phrases: ${spam.join(", ")}.`,
4432
+ hint: "These phrases trigger content filters. Rephrase if possible."
4433
+ });
4434
+ }
4435
+ if (isAllCaps(input.subject)) {
4436
+ issues.push({
4437
+ rule: "all_caps_subject",
4438
+ severity: "warning",
4439
+ message: "Subject line is mostly uppercase.",
4440
+ hint: "All-caps subjects are flagged by spam filters and reduce click-through. Use sentence case."
4441
+ });
4442
+ }
4443
+ if (input.kind === "marketing") {
4444
+ const sources = [input.mjml ?? "", input.html ?? "", JSON.stringify(input.editorJson ?? null)];
4445
+ const hasTag = sources.some((s) => MARKETING_UNSUBSCRIBE_TAG.test(s));
4446
+ if (!hasTag) {
4447
+ issues.push({
4448
+ rule: "missing_unsubscribe_tag",
4449
+ severity: "error",
4450
+ message: "Marketing template is missing the {{unsubscribeUrl}} merge tag.",
4451
+ hint: "CAN-SPAM, GDPR, and Gmail/Yahoo bulk-sender rules all require a working unsubscribe link. Add {{unsubscribeUrl}} somewhere in the body."
4452
+ });
4453
+ }
4454
+ }
4455
+ const senderCheck = validateSenderDomain(input.fromEmail, input.kind, config.senderDomains);
4456
+ if (!senderCheck.ok) {
4457
+ issues.push({
4458
+ rule: "sender_domain_invalid",
4459
+ severity: "error",
4460
+ message: senderCheck.reason,
4461
+ hint: "Edit the template fromEmail to use a domain declared for this kind in senderDomains, or update the registry."
4462
+ });
4463
+ }
4464
+ if (!input.preheader || input.preheader.trim().length === 0) {
4465
+ issues.push({
4466
+ rule: "empty_preheader",
4467
+ severity: "info",
4468
+ message: "No preheader set.",
4469
+ hint: "The preheader shows next to the subject in most inbox previews. A good preheader lifts open rate 5-10%."
4470
+ });
4471
+ }
4472
+ if (input.subject.length > 60) {
4473
+ issues.push({
4474
+ rule: "subject_too_long",
4475
+ severity: "warning",
4476
+ message: `Subject is ${input.subject.length} characters; mobile clients truncate around 60.`,
4477
+ hint: "Front-load the important words so the truncated preview still makes sense."
4478
+ });
4479
+ }
4480
+ if (config.varsJsonSchema) {
4481
+ const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
4482
+ const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
4483
+ if (unknown.length > 0) {
4484
+ issues.push({
4485
+ rule: "unknown_variable",
4486
+ severity: "warning",
4487
+ message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
4488
+ hint: "These render as empty strings. Check for typos, or add the key to your varsAdapter schema. Paths inside {{#each}}/{{#with}} blocks are relative and not checked."
4489
+ });
4490
+ }
4491
+ }
4492
+ const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4493
+ if (linkCount > 10) {
4494
+ issues.push({
4495
+ rule: "too_many_links",
4496
+ severity: "warning",
4497
+ message: `${linkCount} links in body; promotional content with many links is flagged by content filters.`,
4498
+ hint: "Consolidate calls-to-action. Aim for one primary action plus a footer link or two."
4499
+ });
4500
+ }
4501
+ return splitBySeverity(issues);
4502
+ }
4503
+ function splitBySeverity(issues) {
4504
+ const out = { errors: [], warnings: [], infos: [] };
4505
+ for (const i of issues) {
4506
+ if (i.severity === "error") out.errors.push(i);
4507
+ else if (i.severity === "warning") out.warnings.push(i);
4508
+ else out.infos.push(i);
4509
+ }
4510
+ return out;
4511
+ }
4512
+ function countMatches(s, re) {
4513
+ return (s.match(re) ?? []).length;
4514
+ }
4515
+ function findShortenerLinks(html) {
4516
+ const hrefs = extractHrefs(html);
4517
+ const hits = /* @__PURE__ */ new Set();
4518
+ for (const href of hrefs) {
4519
+ const domain = hostnameOf(href);
4520
+ if (!domain) continue;
4521
+ if (URL_SHORTENERS.includes(domain)) hits.add(domain);
4522
+ }
4523
+ return Array.from(hits);
4524
+ }
4525
+ function extractHrefs(html) {
4526
+ const out = [];
4527
+ const re = /<a\s[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
4528
+ let m;
4529
+ while (m = re.exec(html)) {
4530
+ const href = m[1] ?? m[2] ?? m[3];
4531
+ if (href) out.push(href);
4532
+ }
4533
+ return out;
4534
+ }
4535
+ function hostnameOf(url) {
4536
+ try {
4537
+ return new URL(url).hostname.toLowerCase();
4538
+ } catch {
4539
+ return null;
4540
+ }
4541
+ }
4542
+ function hasBareUrlInVisibleText(html) {
4543
+ const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
4544
+ return /https?:\/\/[^\s<>"']+/i.test(stripped);
4545
+ }
4546
+ function findSpamSignals(text) {
4547
+ const out = /* @__PURE__ */ new Set();
4548
+ for (const { pattern, phrase } of SPAM_PHRASES) {
4549
+ if (pattern.test(text)) out.add(phrase);
4550
+ }
4551
+ if (/!{3,}/.test(text)) out.add('excessive "!!!"');
4552
+ return Array.from(out);
4553
+ }
4554
+ var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
4555
+ "contact",
4556
+ "vars",
4557
+ "event",
4558
+ "unsubscribeUrl",
4559
+ "viewInBrowserUrl",
4560
+ "preferenceCenterUrl",
4561
+ "senderAddress",
4562
+ "this"
4563
+ ]);
4564
+ var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
4565
+ function findUnknownVariables(source, schema) {
4566
+ const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
4567
+ const unknown = /* @__PURE__ */ new Set();
4568
+ const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
4569
+ let m;
4570
+ while (m = re.exec(source)) {
4571
+ const expr = m[1].trim();
4572
+ if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
4573
+ if (expr.includes("(")) continue;
4574
+ const parts = expr.split(/\s+/);
4575
+ const candidates = parts.length > 1 ? parts.slice(1) : parts;
4576
+ for (const raw of candidates) {
4577
+ if (!PATH_TOKEN.test(raw)) continue;
4578
+ if (raw.startsWith("@")) continue;
4579
+ const segments = raw.split(".");
4580
+ if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
4581
+ if (hasBlockScopes && segments.length === 1) continue;
4582
+ if (!schemaHasPath(schema, segments)) unknown.add(raw);
4583
+ }
4584
+ }
4585
+ return Array.from(unknown);
4586
+ }
4587
+ function schemaHasPath(node, segments) {
4588
+ if (segments.length === 0) return true;
4589
+ if (!node || typeof node !== "object") return true;
4590
+ const n = node;
4591
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
4592
+ if (Array.isArray(n[key])) {
4593
+ return n[key].some((branch) => schemaHasPath(branch, segments));
4594
+ }
4595
+ }
4596
+ if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
4597
+ if (n.type === "array") {
4598
+ if (segments[0] === "length") return true;
4599
+ return schemaHasPath(n.items, segments);
4600
+ }
4601
+ const props = n.properties;
4602
+ if (!props || typeof props !== "object") return true;
4603
+ const [head, ...rest] = segments;
4604
+ if (head in props) return schemaHasPath(props[head], rest);
4605
+ if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
4606
+ return true;
4607
+ }
4608
+ function isAllCaps(subject) {
4609
+ const letters = subject.replace(/[^a-zA-Z]/g, "");
4610
+ if (letters.length < 5) return false;
4611
+ const upper = subject.replace(/[^A-Z]/g, "").length;
4612
+ return upper / letters.length > 0.5;
4613
+ }
4614
+
4615
+ // src/server/api/admin.ts
4616
+ init_vars();
4617
+
3004
4618
  // src/server/api/setup-status.ts
3005
4619
  async function runSetupChecks(mailer) {
3006
4620
  const checks = [];
@@ -3010,6 +4624,12 @@ async function runSetupChecks(mailer) {
3010
4624
  checks.push(await checkWorkersHeartbeat(mailer));
3011
4625
  }
3012
4626
  checks.push(await checkCircuitBreaker(mailer));
4627
+ checks.push(await checkDnsbl(mailer));
4628
+ checks.push(await checkPostmaster(mailer));
4629
+ checks.push(await checkSnds(mailer));
4630
+ const jmrp = checkJmrp(mailer);
4631
+ if (jmrp) checks.push(jmrp);
4632
+ checks.push(await checkDmarc(mailer));
3013
4633
  checks.push(...checkFromDefaultsAgainstRegistry(mailer));
3014
4634
  checks.push(...await checkPublishedTemplates(mailer));
3015
4635
  checks.push(await checkPostalAddress(mailer));
@@ -3059,7 +4679,10 @@ async function checkQueue(mailer) {
3059
4679
  }
3060
4680
  }
3061
4681
  async function checkWorkersHeartbeat(mailer) {
3062
- const h = await mailer.collections.health.findOne({ _id: "singleton" });
4682
+ const h = await mailer.collections.health.findOne(
4683
+ {},
4684
+ { sort: { updatedAt: -1 } }
4685
+ );
3063
4686
  const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
3064
4687
  const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
3065
4688
  if (!h) {
@@ -3089,25 +4712,264 @@ async function checkWorkersHeartbeat(mailer) {
3089
4712
  };
3090
4713
  }
3091
4714
  async function checkCircuitBreaker(mailer) {
3092
- const h = await mailer.collections.health.findOne({ _id: "singleton" });
3093
- if (!h || h.status === "healthy") {
3094
- return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
4715
+ const docs = await mailer.collections.health.find({}).toArray();
4716
+ const buckets = docs.filter((d) => d._id !== HEALTH_AGG_ID);
4717
+ if (buckets.length === 0) {
4718
+ return {
4719
+ name: "circuit_breaker",
4720
+ label: "Circuit breaker",
4721
+ severity: "ok",
4722
+ message: "no telemetry yet",
4723
+ hint: "The breaker tracks bounce / complaint rates per (sender domain \xD7 kind) and starts evaluating once enough sends have been recorded. Send some mail to populate."
4724
+ };
4725
+ }
4726
+ const tripped = buckets.filter((d) => d.status === "tripped");
4727
+ if (tripped.length > 0) {
4728
+ const summary = tripped.map((d) => `${d.senderDomain ?? "_unknown"}/${d.kind}`).join(", ");
4729
+ return {
4730
+ name: "circuit_breaker",
4731
+ label: "Circuit breaker",
4732
+ severity: "error",
4733
+ message: `tripped: ${summary}`,
4734
+ hint: "Marketing sends are held for the listed bucket(s). Investigate the underlying bounce / complaint cause, then POST /api/health/resume (with optional senderDomain + kind to resume one bucket)."
4735
+ };
3095
4736
  }
3096
- if (h.status === "degraded") {
4737
+ const degraded = buckets.filter((d) => d.status === "degraded");
4738
+ if (degraded.length > 0) {
4739
+ const summary = degraded.map((d) => `${d.senderDomain ?? "_unknown"}/${d.kind}`).join(", ");
3097
4740
  return {
3098
4741
  name: "circuit_breaker",
3099
4742
  label: "Circuit breaker",
3100
4743
  severity: "warn",
3101
- message: "degraded (high failure rate)",
3102
- hint: "Marketing sends still flow but failure rate is above the degraded threshold. Investigate provider errors before they escalate to tripped."
4744
+ message: `degraded: ${summary}`,
4745
+ hint: "Marketing sends still flow but failure rate is above the degraded threshold for the listed bucket(s). Investigate provider errors before they escalate to tripped."
4746
+ };
4747
+ }
4748
+ return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
4749
+ }
4750
+ async function checkDnsbl(mailer) {
4751
+ const docs = await mailer.collections.dnsblChecks.find({}).toArray();
4752
+ if (docs.length === 0) {
4753
+ return {
4754
+ name: "dnsbl",
4755
+ label: "DNS block-list checks",
4756
+ severity: "ok",
4757
+ message: "no checks run yet",
4758
+ hint: "Checks run automatically once per dnsbl.intervalHours (default 24h). To run now, POST /api/dnsbl/recheck."
4759
+ };
4760
+ }
4761
+ const listed = docs.filter((d) => d.result === "listed");
4762
+ if (listed.length > 0) {
4763
+ const summary = listed.map((d) => `${d.target} on ${d.listLabel}`).slice(0, 3).join("; ");
4764
+ const more = listed.length > 3 ? ` (+${listed.length - 3} more)` : "";
4765
+ return {
4766
+ name: "dnsbl",
4767
+ label: "DNS block-list checks",
4768
+ severity: "error",
4769
+ message: `${listed.length} listing(s): ${summary}${more}`,
4770
+ hint: "A sender domain or IP is listed on a public DNSBL. Investigate the listing source \u2014 most lists publish a removal procedure on their website. Mail to the affected (domain, kind) bucket will likely land in spam until delisted."
4771
+ };
4772
+ }
4773
+ const errored = docs.filter((d) => d.result === "error");
4774
+ if (errored.length === docs.length) {
4775
+ return {
4776
+ name: "dnsbl",
4777
+ label: "DNS block-list checks",
4778
+ severity: "warn",
4779
+ message: `all ${docs.length} checks errored`,
4780
+ hint: "Lookups against block lists are failing \u2014 usually a DNS-resolution problem on the host running mailery, or a list rate-limiting your public resolver. Verify outbound DNS works."
3103
4781
  };
3104
4782
  }
3105
4783
  return {
3106
- name: "circuit_breaker",
3107
- label: "Circuit breaker",
3108
- severity: "error",
3109
- message: `tripped: ${h.trippedReason ?? "unknown reason"}`,
3110
- hint: "Marketing sends are held. Investigate the underlying bounce / complaint cause, then POST /api/health/resume."
4784
+ name: "dnsbl",
4785
+ label: "DNS block-list checks",
4786
+ severity: "ok",
4787
+ message: `clean (${docs.length} checks)`
4788
+ };
4789
+ }
4790
+ async function checkPostmaster(mailer) {
4791
+ const cfg = mailer.config.postmaster;
4792
+ if (!cfg?.clientId || !cfg?.clientSecret || !cfg?.refreshToken) {
4793
+ return {
4794
+ name: "postmaster",
4795
+ label: "Google Postmaster Tools",
4796
+ severity: "ok",
4797
+ message: "not configured (optional)",
4798
+ hint: "Set mailer.config.postmaster with an OAuth client + refresh token to pull authoritative Gmail reputation data. Only meaningful at >100 sends/day to Gmail."
4799
+ };
4800
+ }
4801
+ const snapshots = await mailer.collections.postmasterSnapshots.find({}).sort({ fetchedAt: -1 }).toArray();
4802
+ if (snapshots.length === 0) {
4803
+ return {
4804
+ name: "postmaster",
4805
+ label: "Google Postmaster Tools",
4806
+ severity: "warn",
4807
+ message: "configured but no data yet",
4808
+ hint: "A pull runs once per postmaster.intervalHours (default 24h). To run now, POST /api/postmaster/refresh. Postmaster only reports for domains with >100 messages/day to Gmail; smaller senders see empty responses."
4809
+ };
4810
+ }
4811
+ const latestByDomain = /* @__PURE__ */ new Map();
4812
+ for (const s of snapshots) {
4813
+ if (!latestByDomain.has(s.domain)) latestByDomain.set(s.domain, s);
4814
+ }
4815
+ const bad = [];
4816
+ const low = [];
4817
+ for (const s of latestByDomain.values()) {
4818
+ if (s.domainReputation === "BAD") bad.push(s.domain);
4819
+ else if (s.domainReputation === "LOW") low.push(s.domain);
4820
+ }
4821
+ if (bad.length > 0) {
4822
+ return {
4823
+ name: "postmaster",
4824
+ label: "Google Postmaster Tools",
4825
+ severity: "error",
4826
+ message: `BAD reputation on ${bad.join(", ")}`,
4827
+ hint: "Gmail considers these domains spammy. Most mail to Gmail addresses will land in spam folder. Marketing breaker has been tripped for affected domains. See docs/guide/warming.md \xA76 for recovery."
4828
+ };
4829
+ }
4830
+ if (low.length > 0) {
4831
+ return {
4832
+ name: "postmaster",
4833
+ label: "Google Postmaster Tools",
4834
+ severity: "warn",
4835
+ message: `LOW reputation on ${low.join(", ")}`,
4836
+ hint: "Reputation has slipped below Medium. Review recent complaint + bounce activity, slow down marketing volume, and re-engage your most active segment only."
4837
+ };
4838
+ }
4839
+ return {
4840
+ name: "postmaster",
4841
+ label: "Google Postmaster Tools",
4842
+ severity: "ok",
4843
+ // Includes REPUTATION_CATEGORY_UNSPECIFIED ("no data yet") domains —
4844
+ // surface that explicitly so the operator isn't misled.
4845
+ message: (() => {
4846
+ const tiers = new Set(Array.from(latestByDomain.values()).map((d) => d.domainReputation));
4847
+ const hasUnspec = tiers.has("REPUTATION_CATEGORY_UNSPECIFIED");
4848
+ return `${latestByDomain.size} domain(s) tracked${hasUnspec ? " (some have no reputation data yet)" : ", all High/Medium"}`;
4849
+ })()
4850
+ };
4851
+ }
4852
+ function checkJmrp(mailer) {
4853
+ const hasDedicatedIp = (mailer.config.dnsbl?.dedicatedIps?.length ?? 0) > 0;
4854
+ if (!hasDedicatedIp) return null;
4855
+ return {
4856
+ name: "snds_jmrp",
4857
+ label: "Microsoft JMRP enrolment",
4858
+ severity: "ok",
4859
+ message: "manual setup \u2014 confirm in your runbook",
4860
+ hint: "Outlook spam complaints only land in mailery if you enrol each sending IP in JMRP. Visit https://sendersupport.olc.protection.outlook.com/snds/JMRP.aspx and complete the form for each IP. There is no API to verify completion; mailery shows this reminder whenever dedicatedIps is non-empty."
4861
+ };
4862
+ }
4863
+ async function checkSnds(mailer) {
4864
+ const cfg = mailer.config.snds;
4865
+ const hasDedicatedIp = (mailer.config.dnsbl?.dedicatedIps?.length ?? 0) > 0;
4866
+ if (!cfg?.accessKey) {
4867
+ if (!hasDedicatedIp) {
4868
+ return {
4869
+ name: "snds",
4870
+ label: "Microsoft SNDS",
4871
+ severity: "ok",
4872
+ message: "not applicable (no dedicated IP)",
4873
+ hint: "SNDS reports IP-level Outlook/Hotmail reputation. Only meaningful when you send from a dedicated IP. Skip unless you do."
4874
+ };
4875
+ }
4876
+ return {
4877
+ name: "snds",
4878
+ label: "Microsoft SNDS",
4879
+ severity: "warn",
4880
+ message: "dedicated IP configured but SNDS access key not set",
4881
+ hint: "Sign in at https://sendersupport.olc.protection.outlook.com/snds/ , request access for your IP(s), then set mailer.config.snds.accessKey to enable daily pulls. Also enrol in JMRP at https://sendersupport.olc.protection.outlook.com/snds/JMRP.aspx so Outlook complaints flow back to your suppression list."
4882
+ };
4883
+ }
4884
+ const snapshots = await mailer.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).toArray();
4885
+ if (snapshots.length === 0) {
4886
+ return {
4887
+ name: "snds",
4888
+ label: "Microsoft SNDS",
4889
+ severity: "warn",
4890
+ message: "configured but no data yet",
4891
+ hint: "A pull runs every snds.intervalHours (default 24h). To run now, POST /api/snds/refresh. If you just enrolled, SNDS data can take 1-2 days to populate."
4892
+ };
4893
+ }
4894
+ const latestByIp = /* @__PURE__ */ new Map();
4895
+ for (const s of snapshots) {
4896
+ if (!latestByIp.has(s.ip)) latestByIp.set(s.ip, s);
4897
+ }
4898
+ const red = [];
4899
+ const yellow = [];
4900
+ for (const s of latestByIp.values()) {
4901
+ if (s.filterResult === "RED") red.push(s.ip);
4902
+ else if (s.filterResult === "YELLOW") yellow.push(s.ip);
4903
+ }
4904
+ if (red.length > 0) {
4905
+ return {
4906
+ name: "snds",
4907
+ label: "Microsoft SNDS",
4908
+ severity: "error",
4909
+ message: `RED filter on ${red.join(", ")}`,
4910
+ hint: "Outlook/Hotmail is filtering mail from these IPs as spam. Reduce volume, audit recent complaints / trap hits, fix any auth alignment problems, and wait for reputation to recover (typically 1-2 weeks)."
4911
+ };
4912
+ }
4913
+ if (yellow.length > 0) {
4914
+ return {
4915
+ name: "snds",
4916
+ label: "Microsoft SNDS",
4917
+ severity: "warn",
4918
+ message: `YELLOW filter on ${yellow.join(", ")}`,
4919
+ hint: "Outlook is putting some mail from these IPs in the junk folder. Investigate recent complaint rate and trap hits before reputation slips to RED."
4920
+ };
4921
+ }
4922
+ return {
4923
+ name: "snds",
4924
+ label: "Microsoft SNDS",
4925
+ severity: "ok",
4926
+ message: `${latestByIp.size} IP(s) tracked, all GREEN`
4927
+ };
4928
+ }
4929
+ async function checkDmarc(mailer) {
4930
+ const reportCount = await mailer.collections.dmarcReports.estimatedDocumentCount();
4931
+ if (reportCount === 0) {
4932
+ return {
4933
+ name: "dmarc",
4934
+ label: "DMARC RUA reports",
4935
+ severity: "ok",
4936
+ message: "no reports ingested yet",
4937
+ hint: "Publish DMARC `rua=mailto:<your-mailbox>` in DNS, then upload received reports via /admin/mailer/api/dmarc/upload (or drag-drop in the Health screen). RUA tells you who is sending mail as your domain \u2014 both legitimate sources and spoofers."
4938
+ };
4939
+ }
4940
+ const since = new Date(Date.now() - 7 * 864e5);
4941
+ const recent = await mailer.collections.dmarcReports.find({ rangeEnd: { $gte: since } }).toArray();
4942
+ if (recent.length === 0) {
4943
+ return {
4944
+ name: "dmarc",
4945
+ label: "DMARC RUA reports",
4946
+ severity: "warn",
4947
+ message: `no reports in the last 7 days (${reportCount} total)`,
4948
+ hint: "Receivers email RUA reports daily. A gap suggests inbound delivery is broken or the rua= mailbox is no longer monitored. Check inbound mail flow."
4949
+ };
4950
+ }
4951
+ const tagged = await resolveSourceTags(mailer.getRunnerContext());
4952
+ const known = new Set(Array.from(tagged.keys()));
4953
+ const since30 = new Date(Date.now() - 30 * 864e5);
4954
+ const failures = await mailer.collections.dmarcFailures.find({ receivedAt: { $gte: since30 } }).toArray();
4955
+ const unknownFailing = failures.filter((f) => !known.has(f.sourceIp));
4956
+ if (unknownFailing.length === 0) {
4957
+ return {
4958
+ name: "dmarc",
4959
+ label: "DMARC RUA reports",
4960
+ severity: "ok",
4961
+ message: `${recent.length} report(s) this week, no failing unknown sources`
4962
+ };
4963
+ }
4964
+ const totalFailing = unknownFailing.reduce((acc, f) => acc + f.count, 0);
4965
+ const ips = Array.from(new Set(unknownFailing.map((f) => f.sourceIp))).slice(0, 3);
4966
+ const more = unknownFailing.length > 3 ? ` (+${unknownFailing.length - 3} more)` : "";
4967
+ return {
4968
+ name: "dmarc",
4969
+ label: "DMARC RUA reports",
4970
+ severity: "warn",
4971
+ message: `${totalFailing} failing message(s) from ${ips.join(", ")}${more}`,
4972
+ hint: "These IPs sent mail claiming to be from your domain that failed DMARC alignment. Tag known-legit sources in mailer.config.dmarc.knownSources to silence them. Untagged sources are likely either forgotten SaaS tools or active spoofing."
3111
4973
  };
3112
4974
  }
3113
4975
  function checkFromDefaultsAgainstRegistry(mailer) {
@@ -3206,18 +5068,298 @@ async function checkDoiTemplate(mailer) {
3206
5068
  return { name: "doi_template", label: "DOI template", severity: "ok", message: `template "${tpl.slug}" published` };
3207
5069
  }
3208
5070
  return {
3209
- name: "doi_template",
3210
- label: "DOI template",
3211
- severity: "error",
3212
- message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
3213
- hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
5071
+ name: "doi_template",
5072
+ label: "DOI template",
5073
+ severity: "error",
5074
+ message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
5075
+ hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
5076
+ };
5077
+ }
5078
+ function humanDuration(ms) {
5079
+ if (ms < 1e3) return `${ms}ms`;
5080
+ if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
5081
+ if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
5082
+ return `${Math.round(ms / 36e5)}h`;
5083
+ }
5084
+
5085
+ // src/server/runner/hygiene.ts
5086
+ var DAY_MS = 864e5;
5087
+ var HYGIENE_BUCKETS = [
5088
+ { label: "Engaged (last 30 days)", maxDays: 30 },
5089
+ { label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
5090
+ { label: "Engaged (61-90 days)", minDays: 60, maxDays: 90 },
5091
+ { label: "Engaged (91-180 days)", minDays: 90, maxDays: 180 },
5092
+ { label: "Inactive (>180 days)", minDays: 180 },
5093
+ { label: "Never engaged", neverEngaged: true }
5094
+ ];
5095
+ async function computeListHygiene(ctx, opts = {}) {
5096
+ const now = opts.now ?? Date.now();
5097
+ const sunsetDays = opts.sunsetThresholdDays ?? 180;
5098
+ const recentDays = opts.recentWindowDays ?? 90;
5099
+ const recentCutoff = new Date(now - recentDays * DAY_MS);
5100
+ const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
5101
+ const subscribedIds = /* @__PURE__ */ new Set();
5102
+ {
5103
+ const cursor2 = ctx.collections.subscriptions.find({ status: "subscribed" }, { projection: { externalId: 1 } });
5104
+ for await (const s of cursor2) subscribedIds.add(s.externalId);
5105
+ }
5106
+ const cursor = ctx.collections.sends.aggregate(
5107
+ [
5108
+ {
5109
+ $group: {
5110
+ _id: "$externalId",
5111
+ lastOpened: { $max: "$openedAt" },
5112
+ lastClicked: { $max: "$firstClickAt" },
5113
+ firstSent: { $min: "$queuedAt" },
5114
+ totalSends: { $sum: 1 },
5115
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
5116
+ hardBounced: {
5117
+ $sum: {
5118
+ $cond: [
5119
+ { $and: [{ $eq: ["$status", "bounced"] }, { $eq: ["$bounceType", "hard"] }] },
5120
+ 1,
5121
+ 0
5122
+ ]
5123
+ }
5124
+ },
5125
+ complained: { $sum: { $cond: [{ $ne: ["$complainedAt", null] }, 1, 0] } },
5126
+ recentSends: { $sum: { $cond: [{ $gte: ["$queuedAt", recentCutoff] }, 1, 0] } },
5127
+ recentBounced: {
5128
+ $sum: {
5129
+ $cond: [
5130
+ { $and: [{ $eq: ["$status", "bounced"] }, { $gte: ["$queuedAt", recentCutoff] }] },
5131
+ 1,
5132
+ 0
5133
+ ]
5134
+ }
5135
+ },
5136
+ recentComplained: {
5137
+ $sum: {
5138
+ $cond: [
5139
+ { $and: [{ $ne: ["$complainedAt", null] }, { $gte: ["$queuedAt", recentCutoff] }] },
5140
+ 1,
5141
+ 0
5142
+ ]
5143
+ }
5144
+ }
5145
+ }
5146
+ }
5147
+ ],
5148
+ { allowDiskUse: true }
5149
+ );
5150
+ const buckets = HYGIENE_BUCKETS.map((b) => ({
5151
+ label: b.label,
5152
+ minDays: "minDays" in b ? b.minDays : null,
5153
+ maxDays: "maxDays" in b ? b.maxDays : null,
5154
+ neverEngaged: "neverEngaged" in b && b.neverEngaged,
5155
+ count: 0,
5156
+ pctOfTotal: 0
5157
+ }));
5158
+ let totalWithSends = 0;
5159
+ let sunsetCount = 0;
5160
+ const cohortLifetimeAcc = { totalSends: 0, bounced: 0, hardBounced: 0, complained: 0 };
5161
+ const cohortRecentAcc = { totalSends: 0, bounced: 0, complained: 0 };
5162
+ for await (const row of cursor) {
5163
+ if (!subscribedIds.has(row._id)) continue;
5164
+ totalWithSends++;
5165
+ const lastOpened = row.lastOpened ? new Date(row.lastOpened).getTime() : null;
5166
+ const lastClicked = row.lastClicked ? new Date(row.lastClicked).getTime() : null;
5167
+ const lastEngaged = lastOpened == null && lastClicked == null ? null : Math.max(lastOpened ?? 0, lastClicked ?? 0);
5168
+ let isSunset = false;
5169
+ if (lastEngaged == null) {
5170
+ const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
5171
+ buckets[5].count++;
5172
+ if (firstSent != null && now - firstSent > sunsetDays * DAY_MS) {
5173
+ isSunset = true;
5174
+ }
5175
+ } else {
5176
+ const daysSince = (now - lastEngaged) / DAY_MS;
5177
+ if (daysSince <= 30) buckets[0].count++;
5178
+ else if (daysSince <= 60) buckets[1].count++;
5179
+ else if (daysSince <= 90) buckets[2].count++;
5180
+ else if (daysSince <= 180) buckets[3].count++;
5181
+ else {
5182
+ buckets[4].count++;
5183
+ isSunset = true;
5184
+ }
5185
+ }
5186
+ if (isSunset) {
5187
+ sunsetCount++;
5188
+ cohortLifetimeAcc.totalSends += row.totalSends;
5189
+ cohortLifetimeAcc.bounced += row.bounced;
5190
+ cohortLifetimeAcc.hardBounced += row.hardBounced;
5191
+ cohortLifetimeAcc.complained += row.complained;
5192
+ cohortRecentAcc.totalSends += row.recentSends;
5193
+ cohortRecentAcc.bounced += row.recentBounced;
5194
+ cohortRecentAcc.complained += row.recentComplained;
5195
+ }
5196
+ }
5197
+ for (const b of buckets) {
5198
+ b.pctOfTotal = totalSubscribers === 0 ? 0 : b.count / totalSubscribers;
5199
+ }
5200
+ const cohortLifetime = cohortLifetimeAcc.totalSends > 0 ? {
5201
+ totalSends: cohortLifetimeAcc.totalSends,
5202
+ bouncedRate: cohortLifetimeAcc.bounced / cohortLifetimeAcc.totalSends,
5203
+ hardBouncedRate: cohortLifetimeAcc.hardBounced / cohortLifetimeAcc.totalSends,
5204
+ complaintRate: cohortLifetimeAcc.complained / cohortLifetimeAcc.totalSends
5205
+ } : null;
5206
+ let projectedImpact = null;
5207
+ const recentAll = await ctx.collections.sends.aggregate([
5208
+ { $match: { queuedAt: { $gte: recentCutoff } } },
5209
+ {
5210
+ $group: {
5211
+ _id: null,
5212
+ totalSends: { $sum: 1 },
5213
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
5214
+ complained: { $sum: { $cond: [{ $ne: ["$complainedAt", null] }, 1, 0] } }
5215
+ }
5216
+ }
5217
+ ]).toArray();
5218
+ if (recentAll[0] && recentAll[0].totalSends > 0) {
5219
+ const all = recentAll[0];
5220
+ const cohort = cohortRecentAcc;
5221
+ const currentBounceRate = all.bounced / all.totalSends;
5222
+ const currentComplaintRate = all.complained / all.totalSends;
5223
+ const remainingSends = all.totalSends - cohort.totalSends;
5224
+ const remainingBounces = all.bounced - cohort.bounced;
5225
+ const remainingComplaints = all.complained - cohort.complained;
5226
+ const projectedBounceRate = remainingSends > 0 ? remainingBounces / remainingSends : 0;
5227
+ const projectedComplaintRate = remainingSends > 0 ? remainingComplaints / remainingSends : 0;
5228
+ projectedImpact = {
5229
+ recentTotalSends: all.totalSends,
5230
+ recentBounced: all.bounced,
5231
+ recentComplained: all.complained,
5232
+ cohortRecentSends: cohort.totalSends,
5233
+ cohortRecentBounced: cohort.bounced,
5234
+ cohortRecentComplained: cohort.complained,
5235
+ currentOverallBounceRate: currentBounceRate,
5236
+ projectedBounceRate,
5237
+ currentOverallComplaintRate: currentComplaintRate,
5238
+ projectedComplaintRate
5239
+ };
5240
+ }
5241
+ return {
5242
+ totalSubscribers,
5243
+ totalWithSends,
5244
+ buckets,
5245
+ sunsetCandidate: {
5246
+ count: sunsetCount,
5247
+ pctOfTotal: totalSubscribers === 0 ? 0 : sunsetCount / totalSubscribers,
5248
+ cohortLifetime,
5249
+ projectedImpact
5250
+ },
5251
+ computedAt: new Date(now).toISOString()
5252
+ };
5253
+ }
5254
+ var FETCH_TIMEOUT_MS3 = 15e3;
5255
+ function createMailTesterClient(cfg, fetcher = globalThis.fetch) {
5256
+ const base = cfg.baseUrl ?? "https://mail-tester.com/api";
5257
+ function sanitize(s) {
5258
+ if (!cfg.apiKey) return s;
5259
+ return s.split(cfg.apiKey).join("<redacted>").split(encodeURIComponent(cfg.apiKey)).join("<redacted>");
5260
+ }
5261
+ async function call(method, path3, body) {
5262
+ const ctl = new AbortController();
5263
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS3);
5264
+ let res;
5265
+ try {
5266
+ res = await fetcher(`${base}/${encodeURIComponent(cfg.apiKey)}${path3}`, {
5267
+ method,
5268
+ headers: { "content-type": "application/json" },
5269
+ body: body ? JSON.stringify(body) : void 0,
5270
+ signal: ctl.signal
5271
+ });
5272
+ } catch (err) {
5273
+ throw new Error(`Mail-Tester ${method} ${sanitize(path3)} failed: ${sanitize(String(err?.message ?? err))}`);
5274
+ } finally {
5275
+ clearTimeout(t);
5276
+ }
5277
+ if (!res.ok) {
5278
+ const text = await safeText3(res);
5279
+ throw new Error(`Mail-Tester ${method} ${sanitize(path3)} \u2192 ${res.status}: ${sanitize(text)}`);
5280
+ }
5281
+ return res.json();
5282
+ }
5283
+ return {
5284
+ async provisionCheck() {
5285
+ const data = await call("POST", "/new");
5286
+ const checkId = String(data.check_id ?? data.checkId ?? "");
5287
+ const emailAddress = String(data.email_address ?? data.email ?? "");
5288
+ if (!checkId || !emailAddress) throw new Error("Mail-Tester /new returned an unexpected shape");
5289
+ return { checkId, emailAddress };
5290
+ },
5291
+ async fetchResult(checkId) {
5292
+ const data = await call("GET", `/${encodeURIComponent(checkId)}`);
5293
+ const ready = Boolean(data.ready ?? data.complete ?? data.score != null);
5294
+ const scoreRaw = Number(data.score);
5295
+ const score = Number.isFinite(scoreRaw) ? scoreRaw : 0;
5296
+ const feedback = Array.isArray(data.feedback) ? data.feedback.map(normalizeFeedback) : [];
5297
+ const rawSummary = typeof data.summary === "string" ? data.summary : null;
5298
+ return { ready: ready && Number.isFinite(scoreRaw), score, feedback, rawSummary };
5299
+ }
5300
+ };
5301
+ }
5302
+ function normalizeFeedback(raw) {
5303
+ const severity = String(raw.severity ?? "info").toLowerCase();
5304
+ return {
5305
+ category: String(raw.category ?? "other"),
5306
+ severity: severity === "error" || severity === "warning" || severity === "info" ? severity : "info",
5307
+ message: String(raw.message ?? "")
3214
5308
  };
3215
5309
  }
3216
- function humanDuration(ms) {
3217
- if (ms < 1e3) return `${ms}ms`;
3218
- if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
3219
- if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
3220
- return `${Math.round(ms / 36e5)}h`;
5310
+ async function safeText3(res) {
5311
+ try {
5312
+ return await res.text();
5313
+ } catch {
5314
+ return "<no body>";
5315
+ }
5316
+ }
5317
+ function mailTesterContentKey(input) {
5318
+ return crypto2.createHash("sha256").update(`${input.bodyHash}|${input.subject}|${input.fromEmail}`).digest("hex");
5319
+ }
5320
+ async function findCachedScore(ctx, contentKey) {
5321
+ const doc = await ctx.collections.mailTesterScores.findOne({ contentKey });
5322
+ if (!doc) return null;
5323
+ if (new Date(doc.expiresAt).getTime() < Date.now()) return null;
5324
+ return doc;
5325
+ }
5326
+ async function persistScore(ctx, input) {
5327
+ const cfg = ctx.config.mailTester;
5328
+ const hours = cfg?.cacheHours ?? 24;
5329
+ const now = /* @__PURE__ */ new Date();
5330
+ const expiresAt = new Date(now.getTime() + hours * 60 * 60 * 1e3);
5331
+ const doc = {
5332
+ templateSlug: input.templateSlug,
5333
+ contentKey: input.contentKey,
5334
+ checkId: input.checkId,
5335
+ score: input.result.score,
5336
+ feedback: input.result.feedback,
5337
+ rawSummary: input.result.rawSummary,
5338
+ fetchedAt: now,
5339
+ expiresAt
5340
+ };
5341
+ await ctx.collections.mailTesterScores.updateOne(
5342
+ { contentKey: input.contentKey },
5343
+ { $set: doc },
5344
+ { upsert: true }
5345
+ );
5346
+ return doc;
5347
+ }
5348
+ async function evaluateMailTesterGate(ctx, input) {
5349
+ const cfg = ctx.config.mailTester;
5350
+ if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
5351
+ const minScore = cfg.minScore ?? 8;
5352
+ const key = mailTesterContentKey(input);
5353
+ const cached = await findCachedScore(ctx, key);
5354
+ if (!cached) return { allowed: true, reason: null, score: null };
5355
+ if (cached.score < minScore) {
5356
+ return {
5357
+ allowed: false,
5358
+ reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
5359
+ score: cached
5360
+ };
5361
+ }
5362
+ return { allowed: true, reason: null, score: cached };
3221
5363
  }
3222
5364
 
3223
5365
  // src/server/api/admin.ts
@@ -3243,30 +5385,43 @@ function createAdminRouter(mailer, opts = {}) {
3243
5385
  req.actor = getActor(req);
3244
5386
  next();
3245
5387
  });
3246
- router.use("/api", apiRouter(mailer));
5388
+ router.use("/api", apiRouter(mailer, opts));
3247
5389
  router.get(/.*/, (_req, res) => {
3248
5390
  res.sendFile(path.join(spaDir, "index.html"));
3249
5391
  });
3250
5392
  return router;
3251
5393
  }
3252
- function apiRouter(mailer) {
5394
+ function apiRouter(mailer, opts = {}) {
3253
5395
  const r = Router();
3254
5396
  const c = mailer.collections;
5397
+ const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
5398
+ function getMailTesterClient() {
5399
+ if (opts.mailTesterClient) return opts.mailTesterClient;
5400
+ const cfg = mailer.config.mailTester;
5401
+ if (!cfg?.apiKey) return null;
5402
+ return createMailTesterClient(cfg);
5403
+ }
5404
+ r.get(
5405
+ "/vars-schema",
5406
+ asyncHandler(async (_req, res) => {
5407
+ res.json({ schema: varsSchema, builtins: RESERVED_VAR_KEYS });
5408
+ })
5409
+ );
3255
5410
  r.get(
3256
5411
  "/me",
3257
5412
  asyncHandler(async (req, res) => {
3258
- const [flows, templates, broadcasts, contacts, suppressions, health] = await Promise.all([
5413
+ const [flows, templates, broadcasts, contacts, suppressions, healthDocs] = await Promise.all([
3259
5414
  c.flows.estimatedDocumentCount(),
3260
5415
  c.templates.estimatedDocumentCount(),
3261
5416
  c.broadcasts.estimatedDocumentCount(),
3262
5417
  c.subscriptions.countDocuments({ status: "subscribed" }),
3263
5418
  c.suppressions.estimatedDocumentCount(),
3264
- c.health.findOne({ _id: "singleton" })
5419
+ c.health.find({}).limit(500).toArray()
3265
5420
  ]);
3266
5421
  res.json({
3267
5422
  actor: req.actor,
3268
5423
  counts: { flows, templates, broadcasts, contacts, suppressions },
3269
- health: { status: health?.status ?? null },
5424
+ health: { status: effectiveOverallStatus(healthDocs) },
3270
5425
  providers: {
3271
5426
  names: Object.keys(mailer.config.providers),
3272
5427
  default: mailer.config.defaultProvider
@@ -3333,7 +5488,8 @@ function apiRouter(mailer) {
3333
5488
  if (prevD === 0 || curD === 0) return null;
3334
5489
  return curN / curD - prevN / prevD;
3335
5490
  };
3336
- const health = await c.health.findOne({ _id: "singleton" });
5491
+ const healthDocs = await c.health.find({}).limit(500).toArray();
5492
+ const healthAgg = healthDocs.find((d) => d._id === HEALTH_AGG_ID) ?? null;
3337
5493
  const recentFlowsRaw = await c.flows.find({ enabled: true }).limit(5).toArray();
3338
5494
  const flowStatsMap = await computeFlowStats(mailer);
3339
5495
  const recentFlows = recentFlowsRaw.map((f) => ({ ...f, stats: flowStatsMap.get(f.slug) ?? emptyFlowStats() }));
@@ -3362,8 +5518,8 @@ function apiRouter(mailer) {
3362
5518
  },
3363
5519
  series: { hourly: { sends: sendSeries, opens: openSeries } },
3364
5520
  health: {
3365
- status: health?.status ?? null,
3366
- rates: health?.rates ?? null,
5521
+ status: effectiveOverallStatus(healthDocs),
5522
+ rates: healthAgg?.rates ?? null,
3367
5523
  thresholds: {
3368
5524
  hardBounceRatePctTrip: mailer.config.circuitBreaker.hardBounceRatePctTrip,
3369
5525
  complaintRatePctTrip: mailer.config.circuitBreaker.complaintRatePctTrip,
@@ -3383,6 +5539,19 @@ function apiRouter(mailer) {
3383
5539
  });
3384
5540
  })
3385
5541
  );
5542
+ r.get(
5543
+ "/events",
5544
+ asyncHandler(async (_req, res) => {
5545
+ const registered = mailer.events.list();
5546
+ const seenNames = await c.events.distinct("name");
5547
+ const known = new Set(registered.map((r2) => r2.name));
5548
+ const unregistered = seenNames.filter((n) => n && !known.has(n));
5549
+ res.json({
5550
+ registered: registered.sort((a, b) => a.name.localeCompare(b.name)),
5551
+ seen: unregistered.sort()
5552
+ });
5553
+ })
5554
+ );
3386
5555
  r.get(
3387
5556
  "/flows",
3388
5557
  asyncHandler(async (_req, res) => {
@@ -3492,7 +5661,7 @@ function apiRouter(mailer) {
3492
5661
  c.subscriptions.findOne({ externalId }),
3493
5662
  c.events.find({ externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
3494
5663
  c.sends.find({ externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
3495
- c.flowRuns.find({ externalId, status: "active" }).toArray()
5664
+ c.flowRuns.find({ externalId, status: "active" }).sort({ nextActionAt: 1 }).limit(50).toArray()
3496
5665
  ]);
3497
5666
  if (!contact) return res.status(404).json({ error: "not_found" });
3498
5667
  return res.json({ contact, subscription, recentEvents, recentSends, activeRuns });
@@ -3516,7 +5685,7 @@ function apiRouter(mailer) {
3516
5685
  if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
3517
5686
  const send = await c.sends.findOne({ _id: new ObjectId(id) });
3518
5687
  if (!send) return res.status(404).json({ error: "not_found" });
3519
- const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).toArray() : [];
5688
+ const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).sort({ receivedAt: -1 }).limit(100).toArray() : [];
3520
5689
  return res.json({ send, webhookEvents: events });
3521
5690
  })
3522
5691
  );
@@ -3558,7 +5727,6 @@ function apiRouter(mailer) {
3558
5727
  r.get(
3559
5728
  "/health",
3560
5729
  asyncHandler(async (_req, res) => {
3561
- const h = await c.health.findOne({ _id: "singleton" });
3562
5730
  const cb = mailer.config.circuitBreaker;
3563
5731
  const thresholds = {
3564
5732
  hardBounceRatePctTrip: cb.hardBounceRatePctTrip,
@@ -3566,11 +5734,22 @@ function apiRouter(mailer) {
3566
5734
  combinedBounceRatePctTrip: cb.combinedBounceRatePctTrip,
3567
5735
  failedToSendRatePctDegrade: cb.failedToSendRatePctDegrade
3568
5736
  };
3569
- if (!h) {
3570
- res.json({ status: null, rates: null, counters: null, thresholds });
5737
+ const docs = await c.health.find({}).limit(500).toArray();
5738
+ const aggregate = docs.find((d) => d._id === HEALTH_AGG_ID) ?? null;
5739
+ const buckets = docs.filter((d) => d._id !== HEALTH_AGG_ID);
5740
+ const overall = effectiveOverallStatus(docs);
5741
+ if (docs.length === 0) {
5742
+ res.json({ status: null, rates: null, counters: null, aggregate: null, buckets: [], thresholds });
3571
5743
  return;
3572
5744
  }
3573
- res.json({ ...h, thresholds });
5745
+ res.json({
5746
+ status: overall,
5747
+ rates: aggregate?.rates ?? null,
5748
+ counters: aggregate?.counters ?? null,
5749
+ aggregate,
5750
+ buckets,
5751
+ thresholds
5752
+ });
3574
5753
  })
3575
5754
  );
3576
5755
  r.get(
@@ -3583,18 +5762,330 @@ function apiRouter(mailer) {
3583
5762
  r.post(
3584
5763
  "/health/resume",
3585
5764
  asyncHandler(async (req, res) => {
3586
- await c.health.updateOne(
3587
- { _id: "singleton" },
5765
+ const body = req.body ?? {};
5766
+ if (body.senderDomain != null !== (body.kind != null)) {
5767
+ return res.status(400).json({
5768
+ error: "validation_failed",
5769
+ message: "senderDomain and kind must both be provided to target a single bucket, or both omitted to resume all tripped buckets."
5770
+ });
5771
+ }
5772
+ if (body.kind && !["marketing", "transactional"].includes(body.kind)) {
5773
+ return res.status(400).json({ error: "validation_failed", message: 'kind must be "marketing" or "transactional"' });
5774
+ }
5775
+ const target = body.senderDomain && body.kind ? { _id: healthBucketId(body.senderDomain.toLowerCase(), body.kind) } : { status: "tripped", _id: { $ne: HEALTH_AGG_ID } };
5776
+ const result = await c.health.updateMany(
5777
+ target,
3588
5778
  { $set: { status: "healthy", manuallyResumedAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() } }
3589
5779
  );
3590
5780
  await mailer.audit({
3591
5781
  actor: req.actor,
3592
5782
  action: "health.resume",
3593
- resource: { collection: "mailer_health" }
5783
+ resource: {
5784
+ collection: "mailer_health",
5785
+ id: body.senderDomain && body.kind ? `${body.senderDomain}|${body.kind}` : "all-tripped"
5786
+ }
5787
+ });
5788
+ res.json({ ok: true, resumed: result.modifiedCount });
5789
+ })
5790
+ );
5791
+ r.get(
5792
+ "/dnsbl",
5793
+ asyncHandler(async (_req, res) => {
5794
+ const checks = await c.dnsblChecks.find({}).sort({ result: 1, target: 1, list: 1 }).limit(500).toArray();
5795
+ const latestRunAt = checks.reduce((acc, d) => {
5796
+ const t = new Date(d.runAt);
5797
+ return acc && acc.getTime() >= t.getTime() ? acc : t;
5798
+ }, null);
5799
+ res.json({
5800
+ checks,
5801
+ latestRunAt: latestRunAt ? latestRunAt.toISOString() : null,
5802
+ intervalHours: mailer.config.dnsbl?.intervalHours ?? 24
5803
+ });
5804
+ })
5805
+ );
5806
+ r.post(
5807
+ "/dnsbl/recheck",
5808
+ asyncHandler(async (req, res) => {
5809
+ const result = await runDnsblChecks(mailer.getRunnerContext(), { force: true });
5810
+ await mailer.audit({
5811
+ actor: req.actor,
5812
+ action: "dnsbl.recheck",
5813
+ resource: { collection: "mailer_dnsbl_checks" },
5814
+ diffSummary: result.ran ? `${result.totalChecks} checks, ${result.listedCount} listed` : `not run: ${result.reason}`
5815
+ });
5816
+ res.json(result);
5817
+ })
5818
+ );
5819
+ r.get(
5820
+ "/postmaster",
5821
+ asyncHandler(async (_req, res) => {
5822
+ const cfg = mailer.config.postmaster;
5823
+ const configured = !!cfg?.clientId && !!cfg?.clientSecret && !!cfg?.refreshToken;
5824
+ const all = await c.postmasterSnapshots.find({}).sort({ domain: 1, date: -1 }).limit(2e3).toArray();
5825
+ const byDomain = /* @__PURE__ */ new Map();
5826
+ for (const s of all) {
5827
+ const arr = byDomain.get(s.domain) ?? [];
5828
+ if (arr.length < 30) arr.push(s);
5829
+ byDomain.set(s.domain, arr);
5830
+ }
5831
+ const domains = Array.from(byDomain.entries()).map(([domain, snapshots]) => ({
5832
+ domain,
5833
+ latest: snapshots[0] ?? null,
5834
+ history: snapshots
5835
+ }));
5836
+ res.json({
5837
+ configured,
5838
+ intervalHours: cfg?.intervalHours ?? 24,
5839
+ domains
5840
+ });
5841
+ })
5842
+ );
5843
+ r.post(
5844
+ "/postmaster/refresh",
5845
+ asyncHandler(async (req, res) => {
5846
+ const result = await runPostmasterPull(mailer.getRunnerContext(), { force: true });
5847
+ await mailer.audit({
5848
+ actor: req.actor,
5849
+ action: "postmaster.refresh",
5850
+ resource: { collection: "mailer_postmaster_snapshots" },
5851
+ diffSummary: result.ran ? `fetched ${result.fetched ?? 0}, tripped ${(result.trippedDomains ?? []).length}` : `not run: ${result.reason}`
5852
+ });
5853
+ res.json(result);
5854
+ })
5855
+ );
5856
+ let hygieneCache = null;
5857
+ let hygieneInFlight = null;
5858
+ const HYGIENE_CACHE_MS = 6e4;
5859
+ r.get(
5860
+ "/hygiene",
5861
+ asyncHandler(async (req, res) => {
5862
+ const force = req.query.refresh === "1" || req.query.refresh === "true";
5863
+ const now = Date.now();
5864
+ if (!force && hygieneCache && now - hygieneCache.computedAt < HYGIENE_CACHE_MS) {
5865
+ return res.json(hygieneCache.report);
5866
+ }
5867
+ if (!hygieneInFlight) {
5868
+ hygieneInFlight = (async () => {
5869
+ try {
5870
+ const report2 = await computeListHygiene(mailer.getRunnerContext());
5871
+ hygieneCache = { computedAt: Date.now(), report: report2 };
5872
+ return report2;
5873
+ } finally {
5874
+ hygieneInFlight = null;
5875
+ }
5876
+ })();
5877
+ }
5878
+ const report = await hygieneInFlight;
5879
+ res.json(report);
5880
+ })
5881
+ );
5882
+ const dmarcUpload = multer({
5883
+ storage: multer.memoryStorage(),
5884
+ limits: { fileSize: 10 * 1024 * 1024 }
5885
+ // 10 MB; reports are typically <100KB
5886
+ });
5887
+ r.post(
5888
+ "/dmarc/upload",
5889
+ dmarcUpload.single("file"),
5890
+ asyncHandler(async (req, res) => {
5891
+ const file = req.file;
5892
+ if (!file) return res.status(400).json({ error: "no_file", message: 'expected a "file" field' });
5893
+ try {
5894
+ const result = await ingestDmarcAttachment(mailer.getRunnerContext(), file.buffer, file.originalname);
5895
+ await mailer.audit({
5896
+ actor: req.actor,
5897
+ action: "dmarc.ingest",
5898
+ resource: { collection: "mailer_dmarc_reports", id: result.reportId },
5899
+ diffSummary: result.duplicate ? `duplicate report ${result.reportId}` : `ingested ${result.totalMessages} msgs (${result.passCount} pass / ${result.failCount} fail) for ${result.domain}`
5900
+ });
5901
+ return res.json(result);
5902
+ } catch (err) {
5903
+ return res.status(400).json({ error: "ingest_failed", message: String(err?.message ?? err) });
5904
+ }
5905
+ })
5906
+ );
5907
+ r.get(
5908
+ "/dmarc",
5909
+ asyncHandler(async (_req, res) => {
5910
+ const ctx = mailer.getRunnerContext();
5911
+ const tagged = await resolveSourceTags(ctx);
5912
+ const reports = await c.dmarcReports.find({}).sort({ rangeEnd: -1 }).limit(200).toArray();
5913
+ const since30 = new Date(Date.now() - 30 * 864e5);
5914
+ const recentFailures = await c.dmarcFailures.find({ receivedAt: { $gte: since30 } }).sort({ receivedAt: -1 }).limit(5e3).toArray();
5915
+ const byDomain = /* @__PURE__ */ new Map();
5916
+ for (const r2 of reports) {
5917
+ const cur = byDomain.get(r2.domain) ?? { passCount: 0, failCount: 0, totalMessages: 0, reportCount: 0, latestRangeEnd: null, latestPolicy: null, latestPct: null, reports: [] };
5918
+ cur.passCount += r2.passCount;
5919
+ cur.failCount += r2.failCount;
5920
+ cur.totalMessages += r2.totalMessages;
5921
+ cur.reportCount += 1;
5922
+ const re = new Date(r2.rangeEnd);
5923
+ if (!cur.latestRangeEnd || re > cur.latestRangeEnd) {
5924
+ cur.latestRangeEnd = re;
5925
+ cur.latestPolicy = r2.policyP;
5926
+ cur.latestPct = r2.policyPct;
5927
+ }
5928
+ cur.reports.push(r2);
5929
+ byDomain.set(r2.domain, cur);
5930
+ }
5931
+ const since14 = Date.now() - 14 * 864e5;
5932
+ const domains = Array.from(byDomain.entries()).map(([domain, s]) => {
5933
+ const dayTotals = /* @__PURE__ */ new Map();
5934
+ for (const r2 of s.reports) {
5935
+ if (new Date(r2.rangeEnd).getTime() < since14) continue;
5936
+ const day = new Date(r2.rangeEnd).toISOString().slice(0, 10);
5937
+ const d = dayTotals.get(day) ?? { pass: 0, total: 0 };
5938
+ d.pass += r2.passCount;
5939
+ d.total += r2.passCount + r2.failCount;
5940
+ dayTotals.set(day, d);
5941
+ }
5942
+ const series = Array.from(dayTotals.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([day, d]) => ({ day, alignmentRate: d.total === 0 ? null : d.pass / d.total }));
5943
+ const knownIps = new Set(Array.from(tagged.values()).filter((t) => !t.ignored).map((t) => t.ip));
5944
+ const ignoredIps = new Set(Array.from(tagged.values()).filter((t) => t.ignored).map((t) => t.ip));
5945
+ const suggested = suggestPolicyProgression({
5946
+ reports: s.reports.map((r2) => ({ rangeEnd: new Date(r2.rangeEnd), passCount: r2.passCount, failCount: r2.failCount })),
5947
+ failures: recentFailures.filter((f) => f.domain === domain).map((f) => ({ sourceIp: f.sourceIp, count: f.count, receivedAt: new Date(f.receivedAt) })),
5948
+ knownSourceIps: knownIps,
5949
+ ignoredSourceIps: ignoredIps,
5950
+ currentPolicy: s.latestPolicy,
5951
+ currentPct: s.latestPct
5952
+ });
5953
+ return {
5954
+ domain,
5955
+ passCount: s.passCount,
5956
+ failCount: s.failCount,
5957
+ totalMessages: s.totalMessages,
5958
+ reportCount: s.reportCount,
5959
+ latestRangeEnd: s.latestRangeEnd,
5960
+ alignmentRate: s.totalMessages === 0 ? null : s.passCount / s.totalMessages,
5961
+ currentPolicy: s.latestPolicy,
5962
+ currentPct: s.latestPct,
5963
+ progression: suggested ? { ...suggested, current: { policy: s.latestPolicy, pct: s.latestPct } } : null,
5964
+ series
5965
+ };
5966
+ });
5967
+ const topFailures = await c.dmarcFailures.aggregate([
5968
+ { $match: { receivedAt: { $gte: since30 } } },
5969
+ {
5970
+ $group: {
5971
+ _id: { sourceIp: "$sourceIp", domain: "$domain" },
5972
+ total: { $sum: "$count" },
5973
+ days: { $addToSet: "$day" },
5974
+ lastSeen: { $max: "$receivedAt" },
5975
+ sample: { $first: "$$ROOT" }
5976
+ }
5977
+ },
5978
+ { $project: { sourceIp: "$_id.sourceIp", domain: "$_id.domain", total: 1, days: { $size: "$days" }, lastSeen: 1, sample: 1 } },
5979
+ { $sort: { total: -1 } },
5980
+ { $limit: 50 }
5981
+ ]).toArray();
5982
+ const sources = topFailures.map((f) => {
5983
+ const tag = tagged.get(f._id.sourceIp);
5984
+ return {
5985
+ sourceIp: f._id.sourceIp,
5986
+ domain: f._id.domain,
5987
+ totalMessages: f.total,
5988
+ daysSeen: f.days,
5989
+ lastSeen: f.lastSeen,
5990
+ dkimResult: f.sample.dkimResult,
5991
+ spfResult: f.sample.spfResult,
5992
+ dispositionApplied: f.sample.dispositionApplied,
5993
+ label: tag?.label ?? null,
5994
+ ignored: !!tag?.ignored,
5995
+ tagSource: tag?.source ?? null
5996
+ };
5997
+ });
5998
+ res.json({
5999
+ domains,
6000
+ sources,
6001
+ recentReports: reports.slice(0, 30),
6002
+ retentionDays: mailer.config.dmarc?.retentionDays ?? 90
6003
+ });
6004
+ })
6005
+ );
6006
+ r.put(
6007
+ "/dmarc/sources/:ip",
6008
+ asyncHandler(async (req, res) => {
6009
+ const ip = String(req.params.ip ?? "");
6010
+ if (!net.isIP(ip)) {
6011
+ return res.status(400).json({ error: "validation_failed", message: "invalid IP" });
6012
+ }
6013
+ const body = req.body ?? {};
6014
+ if (!body.label || typeof body.label !== "string") {
6015
+ return res.status(400).json({ error: "validation_failed", message: "label is required" });
6016
+ }
6017
+ const label = body.label.trim().slice(0, 200);
6018
+ if (!label) {
6019
+ return res.status(400).json({ error: "validation_failed", message: "label is required" });
6020
+ }
6021
+ const ignored = !!body.ignored;
6022
+ await c.dmarcSourceTags.updateOne(
6023
+ { ip },
6024
+ { $set: { ip, label, ignored, setBy: req.actor ?? "unknown", setAt: /* @__PURE__ */ new Date() } },
6025
+ { upsert: true }
6026
+ );
6027
+ await mailer.audit({
6028
+ actor: req.actor,
6029
+ action: "dmarc.source_tag.upsert",
6030
+ resource: { collection: "mailer_dmarc_source_tags", id: ip },
6031
+ diffSummary: `${ip} \u2192 "${label}"${ignored ? " (ignored)" : ""}`
3594
6032
  });
3595
6033
  res.json({ ok: true });
3596
6034
  })
3597
6035
  );
6036
+ r.delete(
6037
+ "/dmarc/sources/:ip",
6038
+ asyncHandler(async (req, res) => {
6039
+ const ip = String(req.params.ip ?? "");
6040
+ if (!net.isIP(ip)) {
6041
+ return res.status(400).json({ error: "validation_failed", message: "invalid IP" });
6042
+ }
6043
+ const result = await c.dmarcSourceTags.deleteOne({ ip });
6044
+ await mailer.audit({
6045
+ actor: req.actor,
6046
+ action: "dmarc.source_tag.delete",
6047
+ resource: { collection: "mailer_dmarc_source_tags", id: ip }
6048
+ });
6049
+ res.json({ ok: true, deleted: result.deletedCount });
6050
+ })
6051
+ );
6052
+ r.get(
6053
+ "/snds",
6054
+ asyncHandler(async (_req, res) => {
6055
+ const cfg = mailer.config.snds;
6056
+ const configured = !!cfg?.accessKey;
6057
+ const all = await c.sndsSnapshots.find({}).sort({ ip: 1, activityStart: -1 }).limit(2e3).toArray();
6058
+ const byIp = /* @__PURE__ */ new Map();
6059
+ for (const s of all) {
6060
+ const arr = byIp.get(s.ip) ?? [];
6061
+ if (arr.length < 30) arr.push(s);
6062
+ byIp.set(s.ip, arr);
6063
+ }
6064
+ const ips = Array.from(byIp.entries()).map(([ip, snapshots]) => ({
6065
+ ip,
6066
+ latest: snapshots[0] ?? null,
6067
+ history: snapshots
6068
+ }));
6069
+ res.json({
6070
+ configured,
6071
+ intervalHours: cfg?.intervalHours ?? 24,
6072
+ ips
6073
+ });
6074
+ })
6075
+ );
6076
+ r.post(
6077
+ "/snds/refresh",
6078
+ asyncHandler(async (req, res) => {
6079
+ const result = await runSndsPull(mailer.getRunnerContext(), { force: true });
6080
+ await mailer.audit({
6081
+ actor: req.actor,
6082
+ action: "snds.refresh",
6083
+ resource: { collection: "mailer_snds_snapshots" },
6084
+ diffSummary: result.ran ? `parsed ${result.rowsParsed ?? 0}, persisted ${result.rowsPersisted ?? 0}` : `not run: ${result.reason}`
6085
+ });
6086
+ res.json(result);
6087
+ })
6088
+ );
3598
6089
  r.post(
3599
6090
  "/flows",
3600
6091
  asyncHandler(async (req, res) => {
@@ -3835,6 +6326,182 @@ function apiRouter(mailer) {
3835
6326
  return res.json({ ok: true });
3836
6327
  })
3837
6328
  );
6329
+ r.get(
6330
+ "/templates/:slug/mail-tester",
6331
+ asyncHandler(async (req, res) => {
6332
+ const cfg = mailer.config.mailTester;
6333
+ const configured = !!cfg?.apiKey;
6334
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6335
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6336
+ let bodyHash = "";
6337
+ let subject = tpl.subject ?? "";
6338
+ if (tpl.draft) {
6339
+ try {
6340
+ const compiled = tpl.draft.editorJson ? await compileMailyTemplate(tpl.draft.editorJson) : tpl.draft.mjml ? await compileTemplate(tpl.draft.mjml) : null;
6341
+ if (compiled) {
6342
+ bodyHash = sha256Hex(compiled.html);
6343
+ subject = tpl.draft.subject || subject;
6344
+ }
6345
+ } catch {
6346
+ }
6347
+ }
6348
+ if (!bodyHash && tpl.body?.html) {
6349
+ bodyHash = sha256Hex(tpl.body.html);
6350
+ }
6351
+ const key = bodyHash ? mailTesterContentKey({ bodyHash, subject, fromEmail: tpl.fromEmail }) : null;
6352
+ const cached = key ? await findCachedScore(mailer.getRunnerContext(), key) : null;
6353
+ res.json({
6354
+ configured,
6355
+ minScore: cfg?.minScore ?? 8,
6356
+ cacheHours: cfg?.cacheHours ?? 24,
6357
+ score: cached
6358
+ });
6359
+ })
6360
+ );
6361
+ r.post(
6362
+ "/templates/:slug/mail-tester-check",
6363
+ asyncHandler(async (req, res) => {
6364
+ const client = getMailTesterClient();
6365
+ if (!client) {
6366
+ return res.status(400).json({
6367
+ error: "not_configured",
6368
+ message: "Mail-Tester is not configured. Set mailer.config.mailTester.apiKey to enable."
6369
+ });
6370
+ }
6371
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6372
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6373
+ const draft = tpl.draft;
6374
+ if (!draft) return res.status(400).json({ error: "no_draft", message: "Save a draft before running a deliverability check." });
6375
+ let compiled;
6376
+ try {
6377
+ if (draft.editorJson) compiled = await compileMailyTemplate(draft.editorJson);
6378
+ else if (draft.mjml) compiled = await compileTemplate(draft.mjml);
6379
+ else return res.status(400).json({ error: "empty_draft" });
6380
+ } catch (err) {
6381
+ return res.status(400).json({ error: "compile_failed", message: String(err?.message ?? err) });
6382
+ }
6383
+ const bodyHash = sha256Hex(compiled.html);
6384
+ const contentKey = mailTesterContentKey({ bodyHash, subject: draft.subject, fromEmail: tpl.fromEmail });
6385
+ const ctx = mailer.getRunnerContext();
6386
+ const cached = await findCachedScore(ctx, contentKey);
6387
+ if (cached) {
6388
+ return res.json({ cached: true, status: "ready", score: cached });
6389
+ }
6390
+ const { checkId, emailAddress } = await client.provisionCheck();
6391
+ const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
6392
+ if (!provider) {
6393
+ return res.status(500).json({ error: "provider_unknown", message: `default provider ${mailer.config.defaultProvider} is not registered` });
6394
+ }
6395
+ try {
6396
+ await provider.send({
6397
+ to: emailAddress,
6398
+ fromName: tpl.fromName,
6399
+ fromEmail: tpl.fromEmail,
6400
+ replyTo: tpl.replyTo ?? void 0,
6401
+ subject: draft.subject,
6402
+ html: compiled.html,
6403
+ text: compiled.plainText,
6404
+ headers: {},
6405
+ messageMeta: { mailTesterCheckId: checkId }
6406
+ });
6407
+ } catch (err) {
6408
+ return res.status(502).json({ error: "send_failed", message: String(err?.message ?? err) });
6409
+ }
6410
+ await mailer.audit({
6411
+ actor: req.actor,
6412
+ action: "mail_tester.check.start",
6413
+ resource: { collection: "mailer_mail_tester_scores", id: contentKey, slug: tpl.slug },
6414
+ diffSummary: `Sent draft to ${emailAddress} for Mail-Tester check ${checkId}`
6415
+ });
6416
+ res.json({
6417
+ cached: false,
6418
+ status: "pending",
6419
+ checkId,
6420
+ contentKey,
6421
+ message: "Test email sent. Poll /mail-tester-result?checkId=...&contentKey=... for the score."
6422
+ });
6423
+ })
6424
+ );
6425
+ r.get(
6426
+ "/templates/:slug/mail-tester-result",
6427
+ asyncHandler(async (req, res) => {
6428
+ const client = getMailTesterClient();
6429
+ if (!client) return res.status(400).json({ error: "not_configured" });
6430
+ const checkId = String(req.query.checkId ?? "");
6431
+ if (!checkId) return res.status(400).json({ error: "validation_failed", message: "checkId is required" });
6432
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6433
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6434
+ const draft = tpl.draft;
6435
+ if (!draft) return res.status(400).json({ error: "no_draft", message: "Draft no longer exists." });
6436
+ let compiled;
6437
+ try {
6438
+ if (draft.editorJson) compiled = await compileMailyTemplate(draft.editorJson);
6439
+ else if (draft.mjml) compiled = await compileTemplate(draft.mjml);
6440
+ else return res.status(400).json({ error: "empty_draft" });
6441
+ } catch (err) {
6442
+ return res.status(400).json({ error: "compile_failed", message: String(err?.message ?? err) });
6443
+ }
6444
+ const contentKey = mailTesterContentKey({
6445
+ bodyHash: sha256Hex(compiled.html),
6446
+ subject: draft.subject,
6447
+ fromEmail: tpl.fromEmail
6448
+ });
6449
+ const result = await client.fetchResult(checkId);
6450
+ if (!result.ready) {
6451
+ return res.json({ status: "pending", score: null });
6452
+ }
6453
+ const ctx = mailer.getRunnerContext();
6454
+ const persisted = await persistScore(ctx, { templateSlug: tpl.slug, contentKey, checkId, result });
6455
+ await mailer.audit({
6456
+ actor: req.actor,
6457
+ action: "mail_tester.check.complete",
6458
+ resource: { collection: "mailer_mail_tester_scores", id: contentKey, slug: tpl.slug },
6459
+ diffSummary: `Score ${result.score.toFixed(1)}`
6460
+ });
6461
+ res.json({ status: "ready", score: persisted });
6462
+ })
6463
+ );
6464
+ r.post(
6465
+ "/templates/:slug/lint",
6466
+ asyncHandler(async (req, res) => {
6467
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6468
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6469
+ const body = req.body ?? {};
6470
+ const subject = body.subject ?? tpl.draft?.subject ?? tpl.subject ?? "";
6471
+ const preheader = body.preheader ?? tpl.draft?.preheader ?? tpl.preheader ?? "";
6472
+ const mjml = body.mjml ?? tpl.draft?.mjml ?? tpl.body?.mjml ?? "";
6473
+ const editorJson = body.editorJson !== void 0 ? body.editorJson : tpl.draft?.editorJson ?? tpl.body?.editorJson ?? null;
6474
+ const fromEmail = body.fromEmail ?? tpl.fromEmail;
6475
+ const kind = body.kind === "marketing" || body.kind === "transactional" ? body.kind : tpl.kind;
6476
+ let html = "";
6477
+ let plainText = "";
6478
+ try {
6479
+ if (editorJson) {
6480
+ const compiled = await compileMailyTemplate(editorJson);
6481
+ html = compiled.html;
6482
+ plainText = compiled.plainText;
6483
+ } else if (mjml) {
6484
+ const compiled = await compileTemplate(mjml);
6485
+ html = compiled.html;
6486
+ plainText = compiled.plainText;
6487
+ }
6488
+ } catch (err) {
6489
+ return res.status(200).json({
6490
+ errors: [{ rule: "compile_failed", severity: "error", message: `Compile error: ${String(err?.message ?? err)}` }],
6491
+ warnings: [],
6492
+ infos: [],
6493
+ compileFailed: true
6494
+ });
6495
+ }
6496
+ if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
6497
+ if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
6498
+ const lint = lintTemplate(
6499
+ { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
6500
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6501
+ );
6502
+ res.json({ ...lint, compileFailed: false });
6503
+ })
6504
+ );
3838
6505
  r.post(
3839
6506
  "/templates/:slug/publish",
3840
6507
  asyncHandler(async (req, res) => {
@@ -3851,12 +6518,60 @@ function apiRouter(mailer) {
3851
6518
  });
3852
6519
  }
3853
6520
  let compiled;
3854
- if (draft.editorJson) {
3855
- compiled = await compileMailyTemplate(draft.editorJson);
3856
- } else if (draft.mjml) {
3857
- compiled = await compileTemplate(draft.mjml);
3858
- } else {
3859
- return res.status(400).json({ error: "empty_draft", message: "draft has no MJML or editorJson content" });
6521
+ try {
6522
+ if (draft.editorJson) {
6523
+ compiled = await compileMailyTemplate(draft.editorJson);
6524
+ } else if (draft.mjml) {
6525
+ compiled = await compileTemplate(draft.mjml);
6526
+ } else {
6527
+ return res.status(400).json({ error: "empty_draft", message: "draft has no MJML or editorJson content" });
6528
+ }
6529
+ } catch (err) {
6530
+ return res.status(422).json({
6531
+ error: "compile_failed",
6532
+ message: String(err?.message ?? err),
6533
+ lint: {
6534
+ errors: [{ rule: "compile_failed", severity: "error", message: `Compile error: ${String(err?.message ?? err)}` }],
6535
+ warnings: [],
6536
+ infos: []
6537
+ }
6538
+ });
6539
+ }
6540
+ const lint = lintTemplate(
6541
+ {
6542
+ subject: draft.subject,
6543
+ preheader: draft.preheader,
6544
+ mjml: draft.mjml ?? "",
6545
+ editorJson: draft.editorJson,
6546
+ html: compiled.html,
6547
+ plainText: compiled.plainText,
6548
+ kind: tpl.kind,
6549
+ fromEmail: tpl.fromEmail
6550
+ },
6551
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6552
+ );
6553
+ if (lint.errors.length > 0) {
6554
+ return res.status(422).json({
6555
+ error: "lint_failed",
6556
+ message: `Template publish blocked by ${lint.errors.length} content issue(s).`,
6557
+ lint
6558
+ });
6559
+ }
6560
+ const bypass = Boolean(req.body?.bypassMailTester);
6561
+ if (!bypass) {
6562
+ const gate = await evaluateMailTesterGate(mailer.getRunnerContext(), {
6563
+ bodyHash: sha256Hex(compiled.html),
6564
+ subject: draft.subject,
6565
+ fromEmail: tpl.fromEmail
6566
+ });
6567
+ if (!gate.allowed) {
6568
+ return res.status(422).json({
6569
+ error: "mail_tester_blocked",
6570
+ message: gate.reason,
6571
+ score: gate.score,
6572
+ hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
6573
+ });
6574
+ }
3860
6575
  }
3861
6576
  const now = /* @__PURE__ */ new Date();
3862
6577
  const nextVersion = await c.templateVersions.countDocuments({ templateId: tpl._id }) + 1;
@@ -3897,7 +6612,13 @@ function apiRouter(mailer) {
3897
6612
  resource: { collection: "mailer_templates", id: tpl._id, slug: tpl.slug },
3898
6613
  diffSummary: `Published v${nextVersion}`
3899
6614
  });
3900
- return res.json({ ok: true, version: nextVersion, warnings: compiled.errors });
6615
+ return res.json({
6616
+ ok: true,
6617
+ version: nextVersion,
6618
+ warnings: compiled.errors,
6619
+ lint
6620
+ // warnings + infos for UI
6621
+ });
3901
6622
  })
3902
6623
  );
3903
6624
  r.post(
@@ -3919,40 +6640,93 @@ function apiRouter(mailer) {
3919
6640
  html = tpl.body.html;
3920
6641
  plainText = tpl.body.plainText;
3921
6642
  }
3922
- const sampleContact = req.body?.sampleContact ?? {
3923
- externalId: "preview-contact",
3924
- email: "preview@example.com",
3925
- tags: [],
3926
- fields: { firstName: "Alex" }
3927
- };
6643
+ let contact;
6644
+ const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
6645
+ if (contactId) {
6646
+ const found = await mailer.adapter.getById(contactId);
6647
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6648
+ contact = found;
6649
+ } else {
6650
+ contact = req.body?.sampleContact ?? {
6651
+ externalId: "preview-contact",
6652
+ email: "preview@example.com",
6653
+ tags: [],
6654
+ fields: { firstName: "Alex" }
6655
+ };
6656
+ }
6657
+ const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
6658
+ let resolved = {};
6659
+ try {
6660
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6661
+ reason: "preview",
6662
+ templateSlug: tpl.slug,
6663
+ eventProperties
6664
+ });
6665
+ } catch (err) {
6666
+ return res.status(502).json({
6667
+ error: "vars_resolve_failed",
6668
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6669
+ });
6670
+ }
3928
6671
  const renderCtx = {
3929
- contact: sampleContact,
6672
+ ...resolved,
6673
+ contact,
3930
6674
  vars: req.body?.vars ?? {},
6675
+ event: eventProperties ?? {},
3931
6676
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
3932
6677
  senderAddress: mailer.config.senderAddress
3933
6678
  };
3934
6679
  const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
3935
6680
  const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
3936
- return res.json({ subject: rendered.subject, preheader: rendered.preheader, html: rendered.html, plainText: rendered.plainText });
6681
+ return res.json({
6682
+ subject: rendered.subject,
6683
+ preheader: rendered.preheader,
6684
+ html: rendered.html,
6685
+ plainText: rendered.plainText,
6686
+ contact: { externalId: contact.externalId, email: contact.email }
6687
+ });
3937
6688
  })
3938
6689
  );
3939
6690
  r.post(
3940
6691
  "/templates/:slug/send-test",
3941
6692
  asyncHandler(async (req, res) => {
3942
- const { to, sampleData } = req.body ?? {};
6693
+ const { to, sampleData, contactId, eventProperties } = req.body ?? {};
3943
6694
  if (!to) return res.status(400).json({ error: "to_required" });
3944
6695
  const tpl = await c.templates.findOne({ slug: req.params.slug });
3945
6696
  if (!tpl) return res.status(404).json({ error: "not_found" });
3946
- const contact = sampleData?.contact ?? {
3947
- externalId: "test-recipient",
3948
- email: to,
3949
- tags: [],
3950
- fields: { firstName: "Test" }
3951
- };
3952
- contact.email = to;
6697
+ let contact;
6698
+ if (typeof contactId === "string" && contactId) {
6699
+ const found = await mailer.adapter.getById(contactId);
6700
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6701
+ contact = { ...found, email: to };
6702
+ } else {
6703
+ contact = sampleData?.contact ?? {
6704
+ externalId: "test-recipient",
6705
+ email: to,
6706
+ tags: [],
6707
+ fields: { firstName: "Test" }
6708
+ };
6709
+ contact.email = to;
6710
+ }
6711
+ const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
6712
+ let resolved = {};
6713
+ try {
6714
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6715
+ reason: "test",
6716
+ templateSlug: tpl.slug,
6717
+ eventProperties: evProps
6718
+ });
6719
+ } catch (err) {
6720
+ return res.status(502).json({
6721
+ error: "vars_resolve_failed",
6722
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6723
+ });
6724
+ }
3953
6725
  const renderCtx = {
6726
+ ...resolved,
3954
6727
  contact,
3955
6728
  vars: sampleData?.vars ?? {},
6729
+ event: evProps ?? {},
3956
6730
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
3957
6731
  senderAddress: mailer.config.senderAddress
3958
6732
  };
@@ -4603,8 +7377,8 @@ var DEDUPE_POLICIES = [
4603
7377
  ];
4604
7378
 
4605
7379
  // src/server/index.ts
4606
- var VERSION = "0.1.0";
7380
+ var VERSION = "0.7.0";
4607
7381
 
4608
- export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, verifyUnsubscribeToken };
7382
+ export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, varsJsonSchema, verifyUnsubscribeToken };
4609
7383
  //# sourceMappingURL=index.js.map
4610
7384
  //# sourceMappingURL=index.js.map