mailery 0.3.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,13 +6,23 @@ 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
  };
@@ -447,6 +457,17 @@ var predicateSchema = z.lazy(
447
457
  );
448
458
 
449
459
  // src/server/config.ts
460
+ var DEFAULT_DOMAIN_DNSBL_LISTS = [
461
+ { host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
462
+ { host: "multi.surbl.org", label: "SURBL" },
463
+ { host: "multi.uribl.com", label: "URIBL" }
464
+ ];
465
+ var DEFAULT_IP_DNSBL_LISTS = [
466
+ { host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
467
+ { host: "b.barracudacentral.org", label: "Barracuda" },
468
+ { host: "dnsbl.sorbs.net", label: "SORBS" },
469
+ { host: "bl.spamcop.net", label: "SpamCop" }
470
+ ];
450
471
  var DEFAULTS = {
451
472
  collectionPrefix: "mailer_",
452
473
  requireDoubleOptIn: false,
@@ -507,6 +528,10 @@ function resolveConfig(c) {
507
528
  }
508
529
 
509
530
  // src/server/models/index.ts
531
+ var HEALTH_AGG_ID = "agg";
532
+ function healthBucketId(senderDomain, kind) {
533
+ return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
534
+ }
510
535
  function getCollections(db, prefix = "mailer_") {
511
536
  return {
512
537
  subscriptions: db.collection(`${prefix}subscriptions`),
@@ -524,7 +549,14 @@ function getCollections(db, prefix = "mailer_") {
524
549
  auditLog: db.collection(`${prefix}audit_log`),
525
550
  webhookEvents: db.collection(`${prefix}webhook_events`),
526
551
  health: db.collection(`${prefix}health`),
527
- contactTags: db.collection(`${prefix}contact_tags`)
552
+ contactTags: db.collection(`${prefix}contact_tags`),
553
+ dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
554
+ postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
555
+ sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
556
+ dmarcReports: db.collection(`${prefix}dmarc_reports`),
557
+ dmarcFailures: db.collection(`${prefix}dmarc_failures`),
558
+ dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
559
+ mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
528
560
  };
529
561
  }
530
562
  async function ensureIndexes(db, prefix = "mailer_") {
@@ -596,8 +628,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
596
628
  c.contactTags.createIndexes([
597
629
  { key: { externalId: 1, tag: 1 }, unique: true },
598
630
  { key: { tag: 1 } }
631
+ ]),
632
+ c.health.createIndexes([
633
+ { key: { senderDomain: 1, kind: 1 } },
634
+ { key: { status: 1 } },
635
+ // setup-status reads the most-recently-touched health doc as a heartbeat.
636
+ { key: { updatedAt: -1 } }
637
+ ]),
638
+ c.dnsblChecks.createIndexes([
639
+ { key: { target: 1, list: 1 }, unique: true },
640
+ // Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
641
+ { key: { result: 1, target: 1, list: 1 } },
642
+ // TTL — stale rows for targets the operator removed disappear after
643
+ // 60 days without manual cleanup. The puller refreshes runAt on
644
+ // every active target so existing targets stay indefinitely.
645
+ { key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
646
+ ]),
647
+ c.postmasterSnapshots.createIndexes([
648
+ { key: { domain: 1, date: 1 }, unique: true },
649
+ { key: { domain: 1, fetchedAt: -1 } },
650
+ { key: { domainReputation: 1 } }
651
+ ]),
652
+ c.sndsSnapshots.createIndexes([
653
+ { key: { ip: 1, activityStart: 1 }, unique: true },
654
+ { key: { ip: 1, fetchedAt: -1 } },
655
+ { key: { filterResult: 1 } }
656
+ ]),
657
+ c.dmarcReports.createIndexes([
658
+ { key: { reportId: 1, orgName: 1 }, unique: true },
659
+ { key: { domain: 1, rangeEnd: -1 } },
660
+ // Cross-domain "most recent reports" queries scan a lot without this.
661
+ { key: { rangeEnd: -1 } },
662
+ { key: { receivedAt: -1 } }
663
+ ]),
664
+ c.dmarcFailures.createIndexes([
665
+ { key: { reportId: 1, sourceIp: 1 }, unique: true },
666
+ { key: { domain: 1, day: -1 } },
667
+ { key: { sourceIp: 1, day: -1 } },
668
+ { key: { receivedAt: 1 } }
669
+ // for retention pruning
670
+ ]),
671
+ c.dmarcSourceTags.createIndexes([
672
+ { key: { ip: 1 }, unique: true }
673
+ ]),
674
+ c.mailTesterScores.createIndexes([
675
+ { key: { contentKey: 1 }, unique: true },
676
+ { key: { templateSlug: 1, fetchedAt: -1 } },
677
+ // TTL — Mongo auto-deletes expired scores so we never serve stale data.
678
+ { key: { expiresAt: 1 }, expireAfterSeconds: 0 }
599
679
  ])
600
680
  ]);
681
+ await c.health.deleteOne({ _id: "singleton" }).catch(() => {
682
+ });
601
683
  }
602
684
  var EventRegistry = class {
603
685
  policies = /* @__PURE__ */ new Map();
@@ -610,6 +692,9 @@ var EventRegistry = class {
610
692
  policy(name) {
611
693
  return this.policies.get(name);
612
694
  }
695
+ list() {
696
+ return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
697
+ }
613
698
  /**
614
699
  * Derive a dedupeKey for an event call. Returns null when no policy is
615
700
  * registered AND no key was passed — caller should throw.
@@ -1320,21 +1405,88 @@ async function isSuppressed(collections, email, kind) {
1320
1405
  return { suppressed: false };
1321
1406
  }
1322
1407
 
1408
+ // src/server/templates/sender-domain.ts
1409
+ function validateSenderDomain(fromEmail, templateKind, registry) {
1410
+ if (!registry || Object.keys(registry).length === 0) return { ok: true };
1411
+ const domain = extractDomain(fromEmail);
1412
+ if (!domain) {
1413
+ return {
1414
+ ok: false,
1415
+ code: "invalid_email",
1416
+ reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
1417
+ };
1418
+ }
1419
+ const entry = registry[domain];
1420
+ if (!entry) {
1421
+ const known = Object.keys(registry).join(", ");
1422
+ return {
1423
+ ok: false,
1424
+ code: "unregistered_domain",
1425
+ reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
1426
+ };
1427
+ }
1428
+ if (entry.kind === "both") return { ok: true };
1429
+ if (entry.kind !== templateKind) {
1430
+ return {
1431
+ ok: false,
1432
+ code: "wrong_kind",
1433
+ reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
1434
+ };
1435
+ }
1436
+ return { ok: true };
1437
+ }
1438
+ function extractDomain(email) {
1439
+ if (typeof email !== "string") return null;
1440
+ const at = email.lastIndexOf("@");
1441
+ if (at <= 0 || at === email.length - 1) return null;
1442
+ return email.slice(at + 1).toLowerCase().trim();
1443
+ }
1444
+
1323
1445
  // src/server/runner/health.ts
1324
- async function recordHealthCounter(ctx, counter2, by = 1) {
1446
+ var ZERO_COUNTERS = {
1447
+ sent: 0,
1448
+ delivered: 0,
1449
+ bounced: 0,
1450
+ hardBounced: 0,
1451
+ softBounced: 0,
1452
+ complained: 0,
1453
+ failedToSend: 0
1454
+ };
1455
+ var ZERO_RATES = {
1456
+ bounceRate: 0,
1457
+ hardBounceRate: 0,
1458
+ complaintRate: 0,
1459
+ failureRate: 0
1460
+ };
1461
+ async function recordHealthCounter(ctx, counter2, dims, by = 1) {
1462
+ const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
1463
+ const writes = [];
1464
+ writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
1465
+ if (dims) {
1466
+ const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
1467
+ if (domain) {
1468
+ const id = healthBucketId(domain, dims.kind);
1469
+ writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
1470
+ }
1471
+ }
1472
+ await Promise.all(writes);
1473
+ }
1474
+ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
1325
1475
  await ctx.collections.health.updateOne(
1326
- { _id: "singleton" },
1476
+ { _id },
1327
1477
  {
1328
1478
  $inc: { [`counters.${counter2}`]: by },
1329
1479
  $setOnInsert: {
1330
- _id: "singleton",
1480
+ _id,
1481
+ senderDomain,
1482
+ kind,
1331
1483
  windowStartedAt: /* @__PURE__ */ new Date(),
1332
- windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
1484
+ windowDurationMs: windowMs,
1333
1485
  status: "healthy",
1334
1486
  trippedAt: null,
1335
1487
  trippedReason: null,
1336
1488
  manuallyResumedAt: null,
1337
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
1489
+ rates: { ...ZERO_RATES }
1338
1490
  },
1339
1491
  $set: { updatedAt: /* @__PURE__ */ new Date() }
1340
1492
  },
@@ -1344,73 +1496,116 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
1344
1496
  async function evaluateHealth(ctx) {
1345
1497
  const cb = ctx.config.circuitBreaker;
1346
1498
  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()
1499
+ const docs = await ctx.collections.health.find({}).toArray();
1500
+ if (docs.length === 0) return;
1501
+ const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
1502
+ if (!hasAgg) {
1503
+ await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
1504
+ }
1505
+ for (const doc of docs) {
1506
+ const isAgg = doc._id === HEALTH_AGG_ID;
1507
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
1508
+ if (windowAge > windowMs && doc.status !== "tripped") {
1509
+ await ctx.collections.health.updateOne(
1510
+ { _id: doc._id },
1511
+ {
1512
+ $set: {
1513
+ windowStartedAt: /* @__PURE__ */ new Date(),
1514
+ windowDurationMs: windowMs,
1515
+ counters: { ...ZERO_COUNTERS },
1516
+ rates: { ...ZERO_RATES },
1517
+ status: "healthy",
1518
+ updatedAt: /* @__PURE__ */ new Date()
1519
+ }
1360
1520
  }
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) {
1521
+ );
1522
+ continue;
1523
+ }
1524
+ const c = doc.counters;
1525
+ const total = c.sent || 1;
1526
+ const rates = {
1527
+ bounceRate: c.bounced / total,
1528
+ hardBounceRate: c.hardBounced / total,
1529
+ complaintRate: c.complained / total,
1530
+ failureRate: c.failedToSend / total
1531
+ };
1388
1532
  await ctx.collections.health.updateOne(
1389
- { _id: "singleton" },
1390
- { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
1533
+ { _id: doc._id },
1534
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
1391
1535
  );
1392
- if (ctx.config.onCircuitBreakerTrip) {
1393
- try {
1394
- await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
1395
- } catch {
1536
+ if (isAgg) continue;
1537
+ if (c.sent < cb.minSendsBeforeEval) continue;
1538
+ if (doc.status === "tripped") continue;
1539
+ let trippedReason = null;
1540
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
1541
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
1542
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
1543
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
1544
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
1545
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
1546
+ }
1547
+ if (trippedReason) {
1548
+ const result = await ctx.collections.health.updateOne(
1549
+ { _id: doc._id, status: { $in: ["healthy", "degraded"] } },
1550
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
1551
+ );
1552
+ if (result.modifiedCount > 0) {
1553
+ if (ctx.audit) {
1554
+ try {
1555
+ await ctx.audit({
1556
+ actor: "system:circuit-breaker",
1557
+ action: "health.trip",
1558
+ resource: {
1559
+ collection: "mailer_health",
1560
+ id: String(doc._id),
1561
+ slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
1562
+ },
1563
+ diffSummary: trippedReason
1564
+ });
1565
+ } catch {
1566
+ }
1567
+ }
1568
+ if (ctx.config.onCircuitBreakerTrip) {
1569
+ try {
1570
+ await ctx.config.onCircuitBreakerTrip({
1571
+ reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
1572
+ rates
1573
+ });
1574
+ } catch {
1575
+ }
1576
+ }
1396
1577
  }
1578
+ continue;
1397
1579
  }
1398
- return;
1399
- }
1400
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
1401
- if (doc.status !== "degraded") {
1580
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
1581
+ if (doc.status !== "degraded") {
1582
+ await ctx.collections.health.updateOne(
1583
+ { _id: doc._id },
1584
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
1585
+ );
1586
+ }
1587
+ } else if (doc.status === "degraded") {
1402
1588
  await ctx.collections.health.updateOne(
1403
- { _id: "singleton" },
1404
- { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
1589
+ { _id: doc._id },
1590
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
1405
1591
  );
1406
1592
  }
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
1593
  }
1413
1594
  }
1595
+ async function getBucketStatus(ctx, fromEmail, kind) {
1596
+ const domain = fromEmail ? extractDomain(fromEmail) : null;
1597
+ const id = healthBucketId(domain, kind);
1598
+ return ctx.collections.health.findOne({ _id: id });
1599
+ }
1600
+ function effectiveOverallStatus(docs) {
1601
+ const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
1602
+ if (buckets.length === 0) {
1603
+ return docs.length === 0 ? null : "healthy";
1604
+ }
1605
+ if (buckets.some((d) => d.status === "tripped")) return "tripped";
1606
+ if (buckets.some((d) => d.status === "degraded")) return "degraded";
1607
+ return "healthy";
1608
+ }
1414
1609
 
1415
1610
  // src/server/runner/send.ts
1416
1611
  async function handleSend(run, step, contact, flow, ctx) {
@@ -1494,8 +1689,8 @@ async function dispatchSend(sendId, ctx) {
1494
1689
  return;
1495
1690
  }
1496
1691
  if (send.kind === "marketing") {
1497
- const health = await ctx.collections.health.findOne({ _id: "singleton" });
1498
- if (health?.status === "tripped") {
1692
+ const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
1693
+ if (bucket?.status === "tripped") {
1499
1694
  await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
1500
1695
  return;
1501
1696
  }
@@ -1564,13 +1759,13 @@ async function dispatchSend(sendId, ctx) {
1564
1759
  }
1565
1760
  }
1566
1761
  );
1567
- await recordHealthCounter(ctx, "sent");
1762
+ await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
1568
1763
  } catch (err) {
1569
1764
  await ctx.collections.sends.updateOne(
1570
1765
  { _id: send._id },
1571
1766
  { $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
1572
1767
  );
1573
- await recordHealthCounter(ctx, "failedToSend");
1768
+ await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
1574
1769
  if (ctx.config.onSendFailure) {
1575
1770
  try {
1576
1771
  await ctx.config.onSendFailure({ send, error: err });
@@ -2154,7 +2349,15 @@ async function promoteSoftBounces(ctx) {
2154
2349
  const cutoff = new Date(Date.now() - windowDays * 864e5);
2155
2350
  const offenders = await ctx.collections.sends.aggregate([
2156
2351
  { $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
2157
- { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
2352
+ { $sort: { queuedAt: 1 } },
2353
+ {
2354
+ $group: {
2355
+ _id: "$emailAtSend",
2356
+ count: { $sum: 1 },
2357
+ lastFromEmail: { $last: "$fromEmail" },
2358
+ lastKind: { $last: "$kind" }
2359
+ }
2360
+ },
2158
2361
  { $match: { count: { $gte: threshold } } },
2159
2362
  { $limit: 200 }
2160
2363
  ]).toArray();
@@ -2186,73 +2389,976 @@ async function promoteSoftBounces(ctx) {
2186
2389
  { emailAtSubscribe: email },
2187
2390
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
2188
2391
  );
2189
- await recordHealthCounter(ctx, "hardBounced");
2392
+ await recordHealthCounter(
2393
+ ctx,
2394
+ "hardBounced",
2395
+ o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
2396
+ );
2397
+ }
2398
+ }
2399
+ var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
2400
+ var DNS_CONCURRENCY = 8;
2401
+ var defaultResolver = {
2402
+ resolve4: (hostname) => dns.resolve4(hostname)
2403
+ };
2404
+ async function runDnsblChecks(ctx, opts = {}) {
2405
+ const cfg = ctx.config.dnsbl ?? {};
2406
+ const intervalHours = cfg.intervalHours ?? 24;
2407
+ if (!opts.force && intervalHours <= 0) {
2408
+ return { ran: false, reason: "disabled" };
2409
+ }
2410
+ const targets = collectTargets(ctx, cfg);
2411
+ if (targets.domains.length === 0 && targets.ips.length === 0) {
2412
+ return { ran: false, reason: "no_targets" };
2413
+ }
2414
+ const resolver = opts.resolver ?? defaultResolver;
2415
+ const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
2416
+ const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
2417
+ const pairs = [];
2418
+ for (const d of targets.domains) {
2419
+ for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
2420
+ }
2421
+ for (const ip of targets.ips) {
2422
+ for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
2423
+ }
2424
+ const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
2425
+ let duePairs = pairs;
2426
+ if (throttleCutoff != null) {
2427
+ const existing = await ctx.collections.dnsblChecks.find(
2428
+ {
2429
+ $or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
2430
+ },
2431
+ { projection: { target: 1, list: 1, runAt: 1 } }
2432
+ ).toArray();
2433
+ const fresh = /* @__PURE__ */ new Set();
2434
+ for (const e of existing) {
2435
+ if (new Date(e.runAt).getTime() > throttleCutoff) {
2436
+ fresh.add(`${e.target}|${e.list}`);
2437
+ }
2438
+ }
2439
+ duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
2440
+ if (duePairs.length === 0) {
2441
+ return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
2442
+ }
2443
+ }
2444
+ let listedCount = 0;
2445
+ async function processPair(p) {
2446
+ const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
2447
+ const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
2448
+ if (lookup.transient) return;
2449
+ if (lookup.result === "listed") listedCount++;
2450
+ await ctx.collections.dnsblChecks.updateOne(
2451
+ { target: p.target, list: p.list.host },
2452
+ {
2453
+ $set: {
2454
+ target: p.target,
2455
+ targetKind: p.targetKind,
2456
+ list: p.list.host,
2457
+ listLabel: p.list.label,
2458
+ result: lookup.result,
2459
+ returnCodes: lookup.returnCodes,
2460
+ errorMessage: lookup.errorMessage,
2461
+ runAt: /* @__PURE__ */ new Date()
2462
+ }
2463
+ },
2464
+ { upsert: true }
2465
+ );
2466
+ }
2467
+ let idx = 0;
2468
+ await Promise.all(
2469
+ Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
2470
+ while (idx < duePairs.length) {
2471
+ const my = idx++;
2472
+ await processPair(duePairs[my]);
2473
+ }
2474
+ })
2475
+ );
2476
+ return { ran: true, totalChecks: duePairs.length, listedCount };
2477
+ }
2478
+ function collectTargets(ctx, cfg) {
2479
+ const domains = /* @__PURE__ */ new Set();
2480
+ const registry = ctx.config.senderDomains;
2481
+ if (registry) {
2482
+ for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
2483
+ }
2484
+ const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
2485
+ if (fromDomain) domains.add(fromDomain);
2486
+ const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
2487
+ if (txnDomain) domains.add(txnDomain);
2488
+ return {
2489
+ domains: Array.from(domains),
2490
+ ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
2491
+ };
2492
+ }
2493
+ function buildQueryName(target, kind, listHost) {
2494
+ if (kind === "ip") {
2495
+ const v = net.isIP(target);
2496
+ if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
2497
+ if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
2498
+ return null;
2499
+ }
2500
+ const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
2501
+ return `${domain}.${listHost}`;
2502
+ }
2503
+ var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
2504
+ async function queryDnsbl(resolver, query) {
2505
+ try {
2506
+ const records = await resolver.resolve4(query);
2507
+ return interpretRecords(records);
2508
+ } catch (err) {
2509
+ const code = err?.code;
2510
+ if (code === "ENOTFOUND" || code === "ENODATA") {
2511
+ return { result: "clean", returnCodes: [], errorMessage: null };
2512
+ }
2513
+ if (code && TRANSIENT_DNS_CODES.has(code)) {
2514
+ return {
2515
+ transient: true,
2516
+ result: "error",
2517
+ returnCodes: [],
2518
+ errorMessage: String(err?.message ?? err)
2519
+ };
2520
+ }
2521
+ return {
2522
+ result: "error",
2523
+ returnCodes: [],
2524
+ errorMessage: String(err?.message ?? err)
2525
+ };
2526
+ }
2527
+ }
2528
+ function interpretRecords(records) {
2529
+ if (!records || records.length === 0) {
2530
+ return { result: "clean", returnCodes: [], errorMessage: null };
2531
+ }
2532
+ const errorish = records.filter((r) => r.startsWith("127.255.255."));
2533
+ if (errorish.length === records.length) {
2534
+ return {
2535
+ result: "error",
2536
+ returnCodes: records,
2537
+ errorMessage: `list returned reserved code(s): ${records.join(", ")}`
2538
+ };
2539
+ }
2540
+ const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
2541
+ if (listed.length > 0) {
2542
+ return { result: "listed", returnCodes: records, errorMessage: null };
2543
+ }
2544
+ return { result: "clean", returnCodes: records, errorMessage: null };
2545
+ }
2546
+ function reverseIPv4(ip) {
2547
+ return ip.split(".").reverse().join(".");
2548
+ }
2549
+ function reverseIPv6Nibbles(ip) {
2550
+ const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
2551
+ let normalized = ip;
2552
+ if (v4Match) {
2553
+ const octets = v4Match[1].split(".").map((o) => Number(o));
2554
+ if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
2555
+ const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
2556
+ const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
2557
+ normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
2558
+ }
2190
2559
  }
2560
+ const sides = normalized.split("::");
2561
+ let groups;
2562
+ if (sides.length === 1) {
2563
+ groups = sides[0].split(":");
2564
+ } else {
2565
+ const left = sides[0].split(":").filter(Boolean);
2566
+ const right = sides[1].split(":").filter(Boolean);
2567
+ const missing = 8 - left.length - right.length;
2568
+ groups = [...left, ...Array(missing).fill("0"), ...right];
2569
+ }
2570
+ if (groups.length !== 8) {
2571
+ throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
2572
+ }
2573
+ const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
2574
+ return padded.split("").reverse().join(".");
2575
+ }
2576
+ function registrableDomain(domain) {
2577
+ const parsed = psl.parse(domain);
2578
+ if ("domain" in parsed && parsed.domain) return parsed.domain;
2579
+ return domain;
2191
2580
  }
2192
2581
 
2193
- // src/server/runner/tick.ts
2194
- var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
2195
- async function runTick(ctx) {
2582
+ // src/server/runner/postmaster.ts
2583
+ var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
2584
+ var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
2585
+ var FETCH_TIMEOUT_MS = 15e3;
2586
+ async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
2587
+ const ctl = new AbortController();
2588
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
2589
+ try {
2590
+ return await fetcher(url, { ...init, signal: ctl.signal });
2591
+ } finally {
2592
+ clearTimeout(t);
2593
+ }
2594
+ }
2595
+ function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
2596
+ let cached = null;
2597
+ async function getAccessToken() {
2598
+ if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
2599
+ return cached.accessToken;
2600
+ }
2601
+ const body = new URLSearchParams({
2602
+ client_id: cfg.clientId,
2603
+ client_secret: cfg.clientSecret,
2604
+ refresh_token: cfg.refreshToken,
2605
+ grant_type: "refresh_token"
2606
+ });
2607
+ const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
2608
+ method: "POST",
2609
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2610
+ body: body.toString()
2611
+ });
2612
+ if (!res.ok) {
2613
+ throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
2614
+ }
2615
+ const data = await res.json();
2616
+ cached = {
2617
+ accessToken: data.access_token,
2618
+ expiresAt: Date.now() + data.expires_in * 1e3
2619
+ };
2620
+ return cached.accessToken;
2621
+ }
2622
+ async function authedGet(path3) {
2623
+ const token = await getAccessToken();
2624
+ const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path3}`, {
2625
+ method: "GET",
2626
+ headers: { authorization: `Bearer ${token}` }
2627
+ });
2628
+ if (!res.ok) {
2629
+ throw new Error(`Postmaster GET ${path3} failed: ${res.status} ${await safeText(res)}`);
2630
+ }
2631
+ return await res.json();
2632
+ }
2633
+ return {
2634
+ async listDomains() {
2635
+ const data = await authedGet("/domains");
2636
+ return data.domains ?? [];
2637
+ },
2638
+ async getLatestTrafficStats(domain) {
2639
+ const data = await authedGet(
2640
+ `/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
2641
+ );
2642
+ return data.trafficStats?.[0] ?? null;
2643
+ }
2644
+ };
2645
+ }
2646
+ async function safeText(res) {
2647
+ try {
2648
+ return await res.text();
2649
+ } catch {
2650
+ return "<no body>";
2651
+ }
2652
+ }
2653
+ async function runPostmasterPull(ctx, opts = {}) {
2654
+ const cfg = ctx.config.postmaster;
2655
+ if (!cfg) return { ran: false, reason: "not_configured" };
2656
+ const intervalHours = cfg.intervalHours ?? 24;
2657
+ if (!opts.force && intervalHours <= 0) {
2658
+ return { ran: false, reason: "disabled" };
2659
+ }
2660
+ const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
2661
+ const allDomains = resolveDomains(ctx, cfg);
2662
+ if (allDomains.length === 0) {
2663
+ return { ran: false, reason: "no_domains" };
2664
+ }
2665
+ let domains = allDomains;
2666
+ if (!opts.force) {
2667
+ const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
2668
+ const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
2669
+ const lastByDomain = /* @__PURE__ */ new Map();
2670
+ for (const s of latest) {
2671
+ const ts = new Date(s.fetchedAt).getTime();
2672
+ const cur = lastByDomain.get(s.domain) ?? 0;
2673
+ if (ts > cur) lastByDomain.set(s.domain, ts);
2674
+ }
2675
+ domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
2676
+ if (domains.length === 0) {
2677
+ return { ran: false, reason: "not_due" };
2678
+ }
2679
+ }
2680
+ const fetches = await Promise.all(
2681
+ domains.map(async (domain) => {
2682
+ try {
2683
+ const stat = await client.getLatestTrafficStats(domain);
2684
+ return { domain, stat, error: null };
2685
+ } catch (err) {
2686
+ console.error(`mailery: postmaster fetch failed for ${domain}`, err);
2687
+ return { domain, stat: null, error: err };
2688
+ }
2689
+ })
2690
+ );
2691
+ let fetched = 0;
2692
+ const trippedDomains = [];
2693
+ for (const { domain, stat } of fetches) {
2694
+ if (!stat) continue;
2695
+ const snapshot = toSnapshot(domain, stat);
2696
+ if (!snapshot) continue;
2697
+ fetched++;
2698
+ await ctx.collections.postmasterSnapshots.updateOne(
2699
+ { domain: snapshot.domain, date: snapshot.date },
2700
+ { $set: snapshot },
2701
+ { upsert: true }
2702
+ );
2703
+ if (snapshot.domainReputation === "BAD") {
2704
+ const kindsToTrip = kindsForDomain(ctx, domain);
2705
+ for (const kind of kindsToTrip) {
2706
+ const tripped = await tripBucket(ctx, domain, kind, snapshot);
2707
+ if (tripped) trippedDomains.push(`${domain}|${kind}`);
2708
+ }
2709
+ }
2710
+ }
2711
+ return { ran: true, fetched, trippedDomains };
2712
+ }
2713
+ function kindsForDomain(ctx, domain) {
2714
+ const registry = ctx.config.senderDomains ?? {};
2715
+ const entry = registry[domain.toLowerCase()];
2716
+ if (!entry) return [];
2717
+ if (entry.kind === "both") return ["marketing", "transactional"];
2718
+ return [entry.kind];
2719
+ }
2720
+ function resolveDomains(ctx, cfg) {
2721
+ if (cfg.domains && cfg.domains.length > 0) {
2722
+ return cfg.domains.map((d) => d.toLowerCase());
2723
+ }
2724
+ const out = /* @__PURE__ */ new Set();
2725
+ const registry = ctx.config.senderDomains;
2726
+ if (registry) {
2727
+ for (const d of Object.keys(registry)) out.add(d.toLowerCase());
2728
+ }
2729
+ const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
2730
+ if (f) out.add(f);
2731
+ const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
2732
+ if (t) out.add(t);
2733
+ return Array.from(out);
2734
+ }
2735
+ function toSnapshot(domain, stat) {
2736
+ const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
2737
+ if (!m) {
2738
+ console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
2739
+ return null;
2740
+ }
2741
+ const yyyymmdd = m[1];
2742
+ const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
2743
+ return {
2744
+ domain,
2745
+ date,
2746
+ domainReputation: stat.domainReputation ?? null,
2747
+ ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
2748
+ reputation: r.reputation,
2749
+ ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
2750
+ })) : null,
2751
+ userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
2752
+ spfSuccessRatio: stat.spfSuccessRatio ?? null,
2753
+ dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
2754
+ dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
2755
+ outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
2756
+ inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
2757
+ deliveryErrors: stat.deliveryErrors ?? null,
2758
+ spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
2759
+ fetchedAt: /* @__PURE__ */ new Date()
2760
+ };
2761
+ }
2762
+ async function tripBucket(ctx, domain, kind, snapshot) {
2763
+ const id = healthBucketId(domain, kind);
2764
+ const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
2765
+ const prior = await ctx.collections.health.findOne({ _id: id });
2766
+ if (prior?.status === "tripped") {
2767
+ return false;
2768
+ }
2196
2769
  await ctx.collections.health.updateOne(
2197
- { _id: "singleton" },
2770
+ { _id: id },
2198
2771
  {
2199
- $set: { updatedAt: /* @__PURE__ */ new Date() },
2772
+ $set: {
2773
+ status: "tripped",
2774
+ trippedAt: /* @__PURE__ */ new Date(),
2775
+ trippedReason: reason,
2776
+ updatedAt: /* @__PURE__ */ new Date()
2777
+ },
2200
2778
  $setOnInsert: {
2201
- _id: "singleton",
2779
+ _id: id,
2780
+ senderDomain: domain,
2781
+ kind,
2202
2782
  windowStartedAt: /* @__PURE__ */ new Date(),
2203
2783
  windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
2204
- status: "healthy",
2205
- trippedAt: null,
2206
- trippedReason: null,
2207
2784
  manuallyResumedAt: null,
2208
2785
  counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
2209
2786
  rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
2210
2787
  }
2211
2788
  },
2212
2789
  { 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
- });
2790
+ );
2791
+ if (ctx.config.onCircuitBreakerTrip) {
2792
+ try {
2793
+ await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
2794
+ } catch {
2795
+ }
2796
+ }
2797
+ return true;
2236
2798
  }
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() } }
2247
- );
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
- });
2799
+
2800
+ // src/server/runner/snds.ts
2801
+ var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
2802
+ var FETCH_TIMEOUT_MS2 = 3e4;
2803
+ async function runSndsPull(ctx, opts = {}) {
2804
+ const cfg = ctx.config.snds;
2805
+ if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
2806
+ const intervalHours = cfg.intervalHours ?? 24;
2807
+ if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
2808
+ if (!opts.force) {
2809
+ const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
2810
+ if (latest[0]) {
2811
+ const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
2812
+ if (ageMs < intervalHours * 60 * 60 * 1e3) {
2813
+ return { ran: false, reason: "not_due" };
2814
+ }
2815
+ }
2253
2816
  }
2817
+ const fetcher = opts.fetcher ?? globalThis.fetch;
2818
+ const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
2819
+ const ctl = new AbortController();
2820
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
2821
+ let res;
2822
+ try {
2823
+ res = await fetcher(url, { method: "GET", signal: ctl.signal });
2824
+ } catch (err) {
2825
+ throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
2826
+ } finally {
2827
+ clearTimeout(t);
2828
+ }
2829
+ if (!res.ok) {
2830
+ throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
2831
+ }
2832
+ const csv = await res.text();
2833
+ const rows = parseSndsCsv(csv);
2834
+ const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
2835
+ const now = /* @__PURE__ */ new Date();
2836
+ const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
2837
+ updateOne: {
2838
+ filter: { ip: row.ip, activityStart: row.activityStart },
2839
+ update: { $set: { ...row, fetchedAt: now } },
2840
+ upsert: true
2841
+ }
2842
+ }));
2843
+ let persisted = 0;
2844
+ if (ops.length > 0) {
2845
+ const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
2846
+ persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
2847
+ }
2848
+ return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
2254
2849
  }
2255
- async function drainOutbox(ctx) {
2850
+ function redactKey(s, key) {
2851
+ if (!key) return s;
2852
+ return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
2853
+ }
2854
+ async function safeText2(res) {
2855
+ try {
2856
+ return await res.text();
2857
+ } catch {
2858
+ return "<no body>";
2859
+ }
2860
+ }
2861
+ function parseSndsCsv(csv) {
2862
+ const out = [];
2863
+ for (const raw of csv.split(/\r?\n/)) {
2864
+ if (!raw.trim()) continue;
2865
+ const fields = splitCsvRow(raw);
2866
+ if (fields.length < 7) continue;
2867
+ const parsed = parseRow(fields);
2868
+ if (parsed) out.push(parsed);
2869
+ }
2870
+ return out;
2871
+ }
2872
+ function splitCsvRow(row) {
2873
+ const out = [];
2874
+ let cur = "";
2875
+ let inQuotes = false;
2876
+ for (let i = 0; i < row.length; i++) {
2877
+ const ch = row[i];
2878
+ if (inQuotes) {
2879
+ if (ch === '"') {
2880
+ if (row[i + 1] === '"') {
2881
+ cur += '"';
2882
+ i++;
2883
+ } else {
2884
+ inQuotes = false;
2885
+ }
2886
+ } else {
2887
+ cur += ch;
2888
+ }
2889
+ } else if (ch === '"') {
2890
+ inQuotes = true;
2891
+ } else if (ch === ",") {
2892
+ out.push(cur);
2893
+ cur = "";
2894
+ } else {
2895
+ cur += ch;
2896
+ }
2897
+ }
2898
+ out.push(cur);
2899
+ return out.map((f) => f.trim());
2900
+ }
2901
+ function parseRow(fields) {
2902
+ const [
2903
+ ip,
2904
+ activityStart,
2905
+ activityEnd,
2906
+ rcptCommands,
2907
+ dataCommands,
2908
+ messageRecipients,
2909
+ filterResult,
2910
+ complaintRate,
2911
+ trapMessageCount,
2912
+ sampleHelo,
2913
+ sampleMailFrom
2914
+ ] = fields;
2915
+ const start = parseSndsDate(activityStart ?? "");
2916
+ const end = parseSndsDate(activityEnd ?? "");
2917
+ if (!ip || !start || !end) return null;
2918
+ return {
2919
+ ip,
2920
+ activityStart: start,
2921
+ activityEnd: end,
2922
+ rcptCommands: toInt(rcptCommands),
2923
+ dataCommands: toInt(dataCommands),
2924
+ messageRecipients: toInt(messageRecipients),
2925
+ filterResult: normalizeFilter(filterResult ?? ""),
2926
+ complaintRate: parseComplaintRate(complaintRate ?? ""),
2927
+ trapMessageCount: toInt(trapMessageCount),
2928
+ sampleHelo: sampleHelo ? sampleHelo : null,
2929
+ sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
2930
+ };
2931
+ }
2932
+ function toInt(s) {
2933
+ if (!s) return 0;
2934
+ const n = Number(s.replace(/[^\d-]/g, ""));
2935
+ return Number.isFinite(n) ? n : 0;
2936
+ }
2937
+ function normalizeFilter(s) {
2938
+ const u = s.toUpperCase().trim();
2939
+ if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
2940
+ return "UNKNOWN";
2941
+ }
2942
+ function parseComplaintRate(s) {
2943
+ const trimmed = s.trim();
2944
+ if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
2945
+ const cleaned = trimmed.replace(/%/g, "");
2946
+ let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
2947
+ if (m) return Number(m[1]) / 100;
2948
+ m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
2949
+ if (m) return Number(m[1]) / 100;
2950
+ m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
2951
+ if (m) return Number(m[2]) / 100;
2952
+ m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
2953
+ if (m) return Number(m[1]) / 100;
2954
+ return null;
2955
+ }
2956
+ function parseSndsDate(s) {
2957
+ const trimmed = s.trim();
2958
+ if (!trimmed) return null;
2959
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
2960
+ if (m) {
2961
+ const month = Number(m[1]) - 1;
2962
+ const day = Number(m[2]);
2963
+ const year = Number(m[3]);
2964
+ let hour = Number(m[4]);
2965
+ const minute = Number(m[5]);
2966
+ const second = Number(m[6] ?? 0);
2967
+ const meridiem = m[7]?.toUpperCase();
2968
+ if (meridiem === "PM" && hour < 12) hour += 12;
2969
+ if (meridiem === "AM" && hour === 12) hour = 0;
2970
+ const wallMs = Date.UTC(year, month, day, hour, minute, second);
2971
+ if (!Number.isFinite(wallMs)) return null;
2972
+ const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
2973
+ return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
2974
+ }
2975
+ const d = new Date(trimmed);
2976
+ if (!isNaN(d.getTime())) return d;
2977
+ return null;
2978
+ }
2979
+ function isUsPacificDst(year, month0, day) {
2980
+ if (month0 > 2 && month0 < 10) return true;
2981
+ if (month0 < 2 || month0 > 10) return false;
2982
+ if (month0 === 2) {
2983
+ const dst2 = nthSundayOfMonth(year, 2, 2);
2984
+ return day >= dst2;
2985
+ }
2986
+ const dst = nthSundayOfMonth(year, 10, 1);
2987
+ return day < dst;
2988
+ }
2989
+ function nthSundayOfMonth(year, month0, n) {
2990
+ const first = new Date(Date.UTC(year, month0, 1));
2991
+ const dayOfWeek = first.getUTCDay();
2992
+ const firstSunday = 1 + (7 - dayOfWeek) % 7;
2993
+ return firstSunday + (n - 1) * 7;
2994
+ }
2995
+
2996
+ // src/server/runner/dmarc.ts
2997
+ var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
2998
+ function assertSafeZipEntryName(rawName) {
2999
+ if (typeof rawName !== "string" || rawName.length === 0) {
3000
+ throw new Error(`zip entry has unsafe path: <empty>`);
3001
+ }
3002
+ if (rawName.includes("\0")) {
3003
+ throw new Error(`zip entry has unsafe path: contains null byte`);
3004
+ }
3005
+ if (rawName.startsWith("/") || rawName.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(rawName)) {
3006
+ throw new Error(`zip entry has unsafe path: ${rawName}`);
3007
+ }
3008
+ const parts = rawName.split(/[/\\]/);
3009
+ if (parts.some((p) => p === "..")) {
3010
+ throw new Error(`zip entry has unsafe path: ${rawName}`);
3011
+ }
3012
+ }
3013
+ async function extractDmarcXmls(buffer, filename) {
3014
+ const lower = (filename ?? "").toLowerCase();
3015
+ const zlib = await import('zlib');
3016
+ if (lower.endsWith(".gz")) {
3017
+ const xml = await new Promise((resolve, reject) => {
3018
+ const chunks = [];
3019
+ let bytes = 0;
3020
+ const stream = zlib.createGunzip();
3021
+ stream.on("data", (chunk) => {
3022
+ bytes += chunk.length;
3023
+ if (bytes > MAX_DECOMPRESSED_BYTES) {
3024
+ stream.destroy();
3025
+ reject(new Error(`DMARC gzip exceeds ${MAX_DECOMPRESSED_BYTES} bytes (decompression bomb?)`));
3026
+ return;
3027
+ }
3028
+ chunks.push(chunk);
3029
+ });
3030
+ stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
3031
+ stream.on("error", reject);
3032
+ stream.end(buffer);
3033
+ });
3034
+ return [xml];
3035
+ }
3036
+ if (lower.endsWith(".zip")) {
3037
+ const { default: AdmZip } = await import('adm-zip');
3038
+ const zip = new AdmZip(buffer);
3039
+ const entries = zip.getEntries().filter((e) => !e.isDirectory);
3040
+ if (entries.length === 0) throw new Error("zip contains no files");
3041
+ let total = 0;
3042
+ const pieces = [];
3043
+ for (const entry of entries) {
3044
+ assertSafeZipEntryName(entry.entryName);
3045
+ const declaredSize = entry.header?.size;
3046
+ if (typeof declaredSize !== "number" || declaredSize <= 0) {
3047
+ throw new Error(`zip entry ${entry.entryName} has no declared size (refusing to decompress)`);
3048
+ }
3049
+ if (declaredSize > MAX_DECOMPRESSED_BYTES || total + declaredSize > MAX_DECOMPRESSED_BYTES) {
3050
+ throw new Error(`DMARC zip exceeds ${MAX_DECOMPRESSED_BYTES} bytes (decompression bomb?)`);
3051
+ }
3052
+ const data = entry.getData();
3053
+ total += data.length;
3054
+ if (total > MAX_DECOMPRESSED_BYTES) {
3055
+ throw new Error(`DMARC zip decompressed payload exceeds ${MAX_DECOMPRESSED_BYTES} bytes (forged header?)`);
3056
+ }
3057
+ if (declaredSize > 10 * 1024 && data.length > declaredSize * 64 + 1024) {
3058
+ throw new Error(`DMARC zip entry ${entry.entryName} decompressed size (${data.length}) far exceeds declared (${declaredSize})`);
3059
+ }
3060
+ pieces.push(data.toString("utf8"));
3061
+ }
3062
+ return pieces;
3063
+ }
3064
+ if (buffer.length > MAX_DECOMPRESSED_BYTES) {
3065
+ throw new Error(`DMARC payload exceeds ${MAX_DECOMPRESSED_BYTES} bytes`);
3066
+ }
3067
+ return [buffer.toString("utf8")];
3068
+ }
3069
+ function parseDmarcReport(xml) {
3070
+ const { XMLParser } = __require("fast-xml-parser");
3071
+ const parser = new XMLParser({
3072
+ ignoreAttributes: false,
3073
+ parseAttributeValue: false,
3074
+ parseTagValue: false,
3075
+ trimValues: true,
3076
+ // RFC 7489 specifies lowercase snake_case tag names, but some
3077
+ // less-careful receivers emit CamelCase. Normalize before lookup.
3078
+ transformTagName: (t) => t.toLowerCase()
3079
+ });
3080
+ const root = parser.parse(xml);
3081
+ const fb = root?.feedback;
3082
+ if (!fb) throw new Error("DMARC XML missing <feedback> root");
3083
+ const meta = fb.report_metadata ?? {};
3084
+ const policy = fb.policy_published ?? {};
3085
+ const reportId = String(meta.report_id ?? "").trim();
3086
+ const orgName = String(meta.org_name ?? "").trim();
3087
+ const email = String(meta.email ?? "").trim();
3088
+ const domain = String(policy.domain ?? "").trim().toLowerCase();
3089
+ const policyP = policy.p ?? "none";
3090
+ const rawPct = Number(policy.pct ?? 100);
3091
+ const policyPct = Number.isFinite(rawPct) ? Math.max(0, Math.min(100, rawPct)) : 100;
3092
+ if (!reportId) throw new Error("DMARC XML missing report_id");
3093
+ if (!domain) throw new Error("DMARC XML missing policy_published.domain");
3094
+ const range = meta.date_range ?? {};
3095
+ const begin = secondsToDate(range.begin);
3096
+ const end = secondsToDate(range.end);
3097
+ if (!begin || !end) throw new Error("DMARC XML missing date_range");
3098
+ const records = toArray(fb.record);
3099
+ const failures = [];
3100
+ let totalMessages = 0;
3101
+ let passCount = 0;
3102
+ let failCount = 0;
3103
+ const midMs = Math.floor((begin.getTime() + end.getTime()) / 2);
3104
+ const day = new Date(midMs).toISOString().slice(0, 10);
3105
+ for (const rec of records) {
3106
+ const row = rec?.row ?? {};
3107
+ const count = Number(row.count ?? 0) || 0;
3108
+ totalMessages += count;
3109
+ const evald = row.policy_evaluated ?? {};
3110
+ const dkim = evald.dkim ?? "none";
3111
+ const spf = evald.spf ?? "none";
3112
+ const aligned = dkim === "pass" || spf === "pass";
3113
+ if (aligned) {
3114
+ passCount += count;
3115
+ continue;
3116
+ }
3117
+ failCount += count;
3118
+ const sourceIp = String(row.source_ip ?? "").trim();
3119
+ if (!sourceIp) continue;
3120
+ const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
3121
+ const dispositionApplied = String(evald.disposition ?? "none");
3122
+ failures.push({
3123
+ reportId,
3124
+ domain,
3125
+ sourceIp,
3126
+ count,
3127
+ headerFrom,
3128
+ dkimResult: dkim,
3129
+ spfResult: spf,
3130
+ dispositionApplied,
3131
+ day
3132
+ });
3133
+ }
3134
+ return {
3135
+ report: {
3136
+ reportId,
3137
+ orgName,
3138
+ email,
3139
+ domain,
3140
+ policyP,
3141
+ policyPct,
3142
+ rangeStart: begin,
3143
+ rangeEnd: end,
3144
+ totalMessages,
3145
+ passCount,
3146
+ failCount
3147
+ },
3148
+ failures
3149
+ };
3150
+ }
3151
+ function secondsToDate(v) {
3152
+ if (v === void 0 || v === null) return null;
3153
+ const n = typeof v === "number" ? v : Number(String(v));
3154
+ if (!Number.isFinite(n) || n <= 0) return null;
3155
+ return new Date(n * 1e3);
3156
+ }
3157
+ function toArray(v) {
3158
+ if (v === void 0 || v === null) return [];
3159
+ return Array.isArray(v) ? v : [v];
3160
+ }
3161
+ async function ingestDmarcAttachment(ctx, buffer, filename) {
3162
+ const xmls = await extractDmarcXmls(buffer, filename);
3163
+ if (xmls.length === 0) throw new Error("attachment contained no XML payload");
3164
+ const results = [];
3165
+ let firstError = null;
3166
+ for (const xml of xmls) {
3167
+ try {
3168
+ const parsed = parseDmarcReport(xml);
3169
+ results.push(await ingestParsedDmarcReport(ctx, parsed));
3170
+ } catch (err) {
3171
+ if (!firstError) firstError = err;
3172
+ }
3173
+ }
3174
+ if (results.length === 0) {
3175
+ throw firstError ?? new Error("no parseable DMARC reports in attachment");
3176
+ }
3177
+ return { ...results[0], additionalReports: results.length - 1 };
3178
+ }
3179
+ async function ingestParsedDmarcReport(ctx, parsed) {
3180
+ const now = /* @__PURE__ */ new Date();
3181
+ const { report, failures } = parsed;
3182
+ let duplicate = false;
3183
+ try {
3184
+ await ctx.collections.dmarcReports.insertOne({ ...report, receivedAt: now });
3185
+ } catch (err) {
3186
+ if (err?.code === 11e3) {
3187
+ duplicate = true;
3188
+ } else {
3189
+ throw err;
3190
+ }
3191
+ }
3192
+ if (!duplicate && failures.length > 0) {
3193
+ try {
3194
+ await ctx.collections.dmarcFailures.insertMany(
3195
+ failures.map((f) => ({ ...f, receivedAt: now })),
3196
+ { ordered: false }
3197
+ );
3198
+ } catch (err) {
3199
+ const writeErrors = err?.writeErrors;
3200
+ if (Array.isArray(writeErrors)) {
3201
+ const nonDup = writeErrors.find((w) => w?.code !== 11e3);
3202
+ if (nonDup) throw err;
3203
+ } else if (err?.code !== 11e3) {
3204
+ throw err;
3205
+ }
3206
+ }
3207
+ }
3208
+ return {
3209
+ ok: true,
3210
+ reportId: report.reportId,
3211
+ domain: report.domain,
3212
+ rangeStart: report.rangeStart,
3213
+ rangeEnd: report.rangeEnd,
3214
+ totalMessages: report.totalMessages,
3215
+ passCount: report.passCount,
3216
+ failCount: report.failCount,
3217
+ duplicate
3218
+ };
3219
+ }
3220
+ var _lastPruneAt = 0;
3221
+ var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
3222
+ async function pruneDmarcFailures(ctx, opts = {}) {
3223
+ const cfg = ctx.config.dmarc;
3224
+ const days = cfg?.retentionDays ?? 90;
3225
+ if (days <= 0) return 0;
3226
+ if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
3227
+ _lastPruneAt = Date.now();
3228
+ const cutoff = new Date(Date.now() - days * 864e5);
3229
+ const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
3230
+ return r.deletedCount ?? 0;
3231
+ }
3232
+ async function resolveSourceTags(ctx) {
3233
+ const out = /* @__PURE__ */ new Map();
3234
+ for (const t of ctx.config.dmarc?.knownSources ?? []) {
3235
+ out.set(t.ip, { ip: t.ip, label: t.label, ignored: !!t.ignored, source: "config" });
3236
+ }
3237
+ const dbTags = await ctx.collections.dmarcSourceTags.find({}).toArray();
3238
+ for (const t of dbTags) {
3239
+ out.set(t.ip, { ip: t.ip, label: t.label, ignored: t.ignored, source: "db" });
3240
+ }
3241
+ return out;
3242
+ }
3243
+ function suggestPolicyProgression(input) {
3244
+ const { reports, failures, knownSourceIps, ignoredSourceIps, currentPolicy, currentPct } = input;
3245
+ if (currentPolicy === "reject") return null;
3246
+ const since30 = Date.now() - 30 * 864e5;
3247
+ const recentReports = reports.filter((r) => r.rangeEnd.getTime() >= since30);
3248
+ const totalMsgs = recentReports.reduce((acc, r) => acc + r.passCount + r.failCount, 0);
3249
+ const totalPass = recentReports.reduce((acc, r) => acc + r.passCount, 0);
3250
+ if (recentReports.length < 30) return null;
3251
+ if (totalMsgs < 1e3) return null;
3252
+ const untaggedFailures = failures.filter(
3253
+ (f) => f.receivedAt.getTime() >= since30 && !knownSourceIps.has(f.sourceIp) && !ignoredSourceIps.has(f.sourceIp)
3254
+ );
3255
+ if (untaggedFailures.length > 0) return null;
3256
+ const alignmentRate = totalMsgs === 0 ? 0 : totalPass / totalMsgs;
3257
+ if (currentPolicy === null || currentPolicy === "none") {
3258
+ if (alignmentRate >= 0.99) {
3259
+ return {
3260
+ policy: "quarantine",
3261
+ pct: 10,
3262
+ reason: `${(alignmentRate * 100).toFixed(2)}% alignment over ${recentReports.length} reports / ${totalMsgs.toLocaleString()} messages, all known sources tagged.`
3263
+ };
3264
+ }
3265
+ return null;
3266
+ }
3267
+ if (currentPolicy === "quarantine") {
3268
+ const pct = currentPct ?? 100;
3269
+ if (alignmentRate < 0.995) return null;
3270
+ 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.` };
3271
+ 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.` };
3272
+ 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.` };
3273
+ if (alignmentRate >= 0.999) {
3274
+ return { policy: "reject", pct: 100, reason: `Alignment at ${(alignmentRate * 100).toFixed(3)}% with policy=quarantine pct=100 \u2014 safe to move to p=reject.` };
3275
+ }
3276
+ }
3277
+ return null;
3278
+ }
3279
+
3280
+ // src/server/runner/tick.ts
3281
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
3282
+ async function runTick(ctx) {
3283
+ try {
3284
+ await ctx.collections.health.updateOne(
3285
+ { _id: HEALTH_AGG_ID },
3286
+ {
3287
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
3288
+ $setOnInsert: {
3289
+ _id: HEALTH_AGG_ID,
3290
+ senderDomain: null,
3291
+ kind: null,
3292
+ windowStartedAt: /* @__PURE__ */ new Date(),
3293
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
3294
+ status: "healthy",
3295
+ trippedAt: null,
3296
+ trippedReason: null,
3297
+ manuallyResumedAt: null,
3298
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3299
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3300
+ }
3301
+ },
3302
+ { upsert: true }
3303
+ );
3304
+ } catch (err) {
3305
+ console.error("mailery: heartbeat write failed", err);
3306
+ }
3307
+ await processNewlyFiredEventTriggers(ctx).catch((err) => {
3308
+ console.error("mailery: triggers scan failed", err);
3309
+ });
3310
+ await sweepStrandedFlowRuns(ctx).catch((err) => {
3311
+ console.error("mailery: sweep failed", err);
3312
+ });
3313
+ await sweepStrandedSends(ctx).catch((err) => {
3314
+ console.error("mailery: stranded-send sweep failed", err);
3315
+ });
3316
+ await drainOutbox(ctx).catch((err) => {
3317
+ console.error("mailery: outbox drain failed", err);
3318
+ });
3319
+ await processScheduledBroadcasts2(ctx).catch((err) => {
3320
+ console.error("mailery: broadcast dispatch failed", err);
3321
+ });
3322
+ await evaluateHealth(ctx).catch((err) => {
3323
+ console.error("mailery: health evaluation failed", err);
3324
+ });
3325
+ await promoteSoftBounces(ctx).catch((err) => {
3326
+ console.error("mailery: soft-bounce promotion failed", err);
3327
+ });
3328
+ await Promise.all([
3329
+ runDnsblChecks(ctx).catch((err) => {
3330
+ console.error("mailery: dnsbl checks failed", err);
3331
+ }),
3332
+ runPostmasterPull(ctx).catch((err) => {
3333
+ console.error("mailery: postmaster pull failed", err);
3334
+ }),
3335
+ runSndsPull(ctx).catch((err) => {
3336
+ console.error("mailery: snds pull failed", err);
3337
+ }),
3338
+ pruneDmarcFailures(ctx).catch((err) => {
3339
+ console.error("mailery: dmarc prune failed", err);
3340
+ })
3341
+ ]);
3342
+ }
3343
+ async function sweepStrandedSends(ctx) {
3344
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
3345
+ const cursor = ctx.collections.sends.find(
3346
+ { status: "sending", updatedAt: { $lt: cutoff } },
3347
+ { projection: { _id: 1 } }
3348
+ ).limit(500);
3349
+ for await (const row of cursor) {
3350
+ const reset = await ctx.collections.sends.updateOne(
3351
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
3352
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
3353
+ );
3354
+ if (reset.modifiedCount === 0) continue;
3355
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
3356
+ attempts: ctx.config.sendRetryAttempts,
3357
+ backoff: { type: "exponential", delay: 6e4 }
3358
+ });
3359
+ }
3360
+ }
3361
+ async function drainOutbox(ctx) {
2256
3362
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
2257
3363
  for (const row of batch) {
2258
3364
  try {
@@ -2290,6 +3396,10 @@ async function processScheduledBroadcasts2(ctx) {
2290
3396
  }
2291
3397
 
2292
3398
  // src/server/runner/webhook.ts
3399
+ function dimsFromSend(send) {
3400
+ if (!send) return null;
3401
+ return { fromEmail: send.fromEmail, kind: send.kind };
3402
+ }
2293
3403
  async function applyWebhookEvent(event, ctx) {
2294
3404
  const send = await ctx.collections.sends.findOne(
2295
3405
  event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
@@ -2303,7 +3413,7 @@ async function applyWebhookEvent(event, ctx) {
2303
3413
  { $set: { status: "delivered", deliveredAt: event.occurredAt } }
2304
3414
  );
2305
3415
  }
2306
- await recordHealthCounter(ctx, "delivered");
3416
+ await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
2307
3417
  break;
2308
3418
  case "open":
2309
3419
  if (send) {
@@ -2358,8 +3468,11 @@ async function applyWebhookEvent(event, ctx) {
2358
3468
  { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
2359
3469
  );
2360
3470
  }
2361
- await recordHealthCounter(ctx, "bounced");
2362
- await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
3471
+ {
3472
+ const dims = dimsFromSend(send);
3473
+ await recordHealthCounter(ctx, "bounced", dims);
3474
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
3475
+ }
2363
3476
  break;
2364
3477
  }
2365
3478
  case "complaint":
@@ -2375,7 +3488,7 @@ async function applyWebhookEvent(event, ctx) {
2375
3488
  { emailAtSubscribe: event.email },
2376
3489
  { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
2377
3490
  );
2378
- await recordHealthCounter(ctx, "complained");
3491
+ await recordHealthCounter(ctx, "complained", dimsFromSend(send));
2379
3492
  break;
2380
3493
  case "unsubscribe":
2381
3494
  if (send) {
@@ -2444,7 +3557,8 @@ var Mailer = class _Mailer {
2444
3557
  providers: this.providers,
2445
3558
  queues: this.queues,
2446
3559
  config: this.config,
2447
- handlebarsHelpers: this.config.handlebarsHelpers
3560
+ handlebarsHelpers: this.config.handlebarsHelpers,
3561
+ audit: (entry) => this.audit(entry)
2448
3562
  };
2449
3563
  }
2450
3564
  /**
@@ -2934,43 +4048,6 @@ var Mailer = class _Mailer {
2934
4048
  }
2935
4049
  };
2936
4050
 
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
4051
  // src/server/index.ts
2975
4052
  init_mongo();
2976
4053
 
@@ -3001,6 +4078,185 @@ var NullProvider = class {
3001
4078
  // src/server/index.ts
3002
4079
  init_sendgrid();
3003
4080
 
4081
+ // src/server/templates/linter.ts
4082
+ var URL_SHORTENERS = ["bit.ly", "t.co", "tinyurl.com", "goo.gl", "ow.ly"];
4083
+ var SPAM_PHRASES = [
4084
+ { pattern: /\bFREE\b/, phrase: "FREE" },
4085
+ { pattern: /\bACT NOW\b/i, phrase: "ACT NOW" },
4086
+ { pattern: /\b100%\s+guaranteed\b/i, phrase: "100% guaranteed" }
4087
+ ];
4088
+ var MARKETING_UNSUBSCRIBE_TAG = /\{\{\s*unsubscribeUrl\s*\}\}/;
4089
+ function lintTemplate(rawInput, config = {}) {
4090
+ const input = {
4091
+ subject: typeof rawInput.subject === "string" ? rawInput.subject : "",
4092
+ preheader: typeof rawInput.preheader === "string" ? rawInput.preheader : "",
4093
+ mjml: typeof rawInput.mjml === "string" ? rawInput.mjml : "",
4094
+ editorJson: rawInput.editorJson,
4095
+ html: typeof rawInput.html === "string" ? rawInput.html : "",
4096
+ plainText: typeof rawInput.plainText === "string" ? rawInput.plainText : "",
4097
+ kind: rawInput.kind === "marketing" || rawInput.kind === "transactional" ? rawInput.kind : "transactional",
4098
+ fromEmail: typeof rawInput.fromEmail === "string" ? rawInput.fromEmail : ""
4099
+ };
4100
+ const issues = [];
4101
+ if (!input.plainText || input.plainText.trim().length === 0) {
4102
+ issues.push({
4103
+ rule: "missing_plain_text",
4104
+ severity: "error",
4105
+ message: "Plain-text alternative is empty.",
4106
+ 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."
4107
+ });
4108
+ }
4109
+ const imageCount = countMatches(input.html, /<img\b/gi);
4110
+ const textLen = input.plainText.trim().length;
4111
+ if (imageCount >= 1 && textLen < 20) {
4112
+ issues.push({
4113
+ rule: "image_only_body",
4114
+ severity: "error",
4115
+ message: "Body is mostly images with little or no text.",
4116
+ hint: "Image-only emails are heavily filtered as spam. Add at least one paragraph of meaningful text."
4117
+ });
4118
+ }
4119
+ const shortenerLinks = findShortenerLinks(input.html);
4120
+ if (shortenerLinks.length > 0) {
4121
+ issues.push({
4122
+ rule: "url_shortener",
4123
+ severity: "error",
4124
+ message: `Link uses a URL shortener: ${shortenerLinks.join(", ")}.`,
4125
+ hint: "URL shorteners are strongly correlated with spam and are routinely blocked. Use a direct link to your domain."
4126
+ });
4127
+ }
4128
+ if (hasBareUrlInVisibleText(input.html)) {
4129
+ issues.push({
4130
+ rule: "bare_url",
4131
+ severity: "warning",
4132
+ message: "A URL appears in the body text without being wrapped in a link.",
4133
+ hint: "Wrap visible URLs in an anchor tag \u2014 bare URLs look like spam template residue and reduce engagement."
4134
+ });
4135
+ }
4136
+ const spam = findSpamSignals(`${input.subject}
4137
+ ${input.plainText}`);
4138
+ if (spam.length > 0) {
4139
+ issues.push({
4140
+ rule: "spam_phrases",
4141
+ severity: "warning",
4142
+ message: `Content contains spam-flagged phrases: ${spam.join(", ")}.`,
4143
+ hint: "These phrases trigger content filters. Rephrase if possible."
4144
+ });
4145
+ }
4146
+ if (isAllCaps(input.subject)) {
4147
+ issues.push({
4148
+ rule: "all_caps_subject",
4149
+ severity: "warning",
4150
+ message: "Subject line is mostly uppercase.",
4151
+ hint: "All-caps subjects are flagged by spam filters and reduce click-through. Use sentence case."
4152
+ });
4153
+ }
4154
+ if (input.kind === "marketing") {
4155
+ const sources = [input.mjml ?? "", input.html ?? "", JSON.stringify(input.editorJson ?? null)];
4156
+ const hasTag = sources.some((s) => MARKETING_UNSUBSCRIBE_TAG.test(s));
4157
+ if (!hasTag) {
4158
+ issues.push({
4159
+ rule: "missing_unsubscribe_tag",
4160
+ severity: "error",
4161
+ message: "Marketing template is missing the {{unsubscribeUrl}} merge tag.",
4162
+ hint: "CAN-SPAM, GDPR, and Gmail/Yahoo bulk-sender rules all require a working unsubscribe link. Add {{unsubscribeUrl}} somewhere in the body."
4163
+ });
4164
+ }
4165
+ }
4166
+ const senderCheck = validateSenderDomain(input.fromEmail, input.kind, config.senderDomains);
4167
+ if (!senderCheck.ok) {
4168
+ issues.push({
4169
+ rule: "sender_domain_invalid",
4170
+ severity: "error",
4171
+ message: senderCheck.reason,
4172
+ hint: "Edit the template fromEmail to use a domain declared for this kind in senderDomains, or update the registry."
4173
+ });
4174
+ }
4175
+ if (!input.preheader || input.preheader.trim().length === 0) {
4176
+ issues.push({
4177
+ rule: "empty_preheader",
4178
+ severity: "info",
4179
+ message: "No preheader set.",
4180
+ hint: "The preheader shows next to the subject in most inbox previews. A good preheader lifts open rate 5-10%."
4181
+ });
4182
+ }
4183
+ if (input.subject.length > 60) {
4184
+ issues.push({
4185
+ rule: "subject_too_long",
4186
+ severity: "warning",
4187
+ message: `Subject is ${input.subject.length} characters; mobile clients truncate around 60.`,
4188
+ hint: "Front-load the important words so the truncated preview still makes sense."
4189
+ });
4190
+ }
4191
+ const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4192
+ if (linkCount > 10) {
4193
+ issues.push({
4194
+ rule: "too_many_links",
4195
+ severity: "warning",
4196
+ message: `${linkCount} links in body; promotional content with many links is flagged by content filters.`,
4197
+ hint: "Consolidate calls-to-action. Aim for one primary action plus a footer link or two."
4198
+ });
4199
+ }
4200
+ return splitBySeverity(issues);
4201
+ }
4202
+ function splitBySeverity(issues) {
4203
+ const out = { errors: [], warnings: [], infos: [] };
4204
+ for (const i of issues) {
4205
+ if (i.severity === "error") out.errors.push(i);
4206
+ else if (i.severity === "warning") out.warnings.push(i);
4207
+ else out.infos.push(i);
4208
+ }
4209
+ return out;
4210
+ }
4211
+ function countMatches(s, re) {
4212
+ return (s.match(re) ?? []).length;
4213
+ }
4214
+ function findShortenerLinks(html) {
4215
+ const hrefs = extractHrefs(html);
4216
+ const hits = /* @__PURE__ */ new Set();
4217
+ for (const href of hrefs) {
4218
+ const domain = hostnameOf(href);
4219
+ if (!domain) continue;
4220
+ if (URL_SHORTENERS.includes(domain)) hits.add(domain);
4221
+ }
4222
+ return Array.from(hits);
4223
+ }
4224
+ function extractHrefs(html) {
4225
+ const out = [];
4226
+ const re = /<a\s[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
4227
+ let m;
4228
+ while (m = re.exec(html)) {
4229
+ const href = m[1] ?? m[2] ?? m[3];
4230
+ if (href) out.push(href);
4231
+ }
4232
+ return out;
4233
+ }
4234
+ function hostnameOf(url) {
4235
+ try {
4236
+ return new URL(url).hostname.toLowerCase();
4237
+ } catch {
4238
+ return null;
4239
+ }
4240
+ }
4241
+ function hasBareUrlInVisibleText(html) {
4242
+ const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
4243
+ return /https?:\/\/[^\s<>"']+/i.test(stripped);
4244
+ }
4245
+ function findSpamSignals(text) {
4246
+ const out = /* @__PURE__ */ new Set();
4247
+ for (const { pattern, phrase } of SPAM_PHRASES) {
4248
+ if (pattern.test(text)) out.add(phrase);
4249
+ }
4250
+ if (/!{3,}/.test(text)) out.add('excessive "!!!"');
4251
+ return Array.from(out);
4252
+ }
4253
+ function isAllCaps(subject) {
4254
+ const letters = subject.replace(/[^a-zA-Z]/g, "");
4255
+ if (letters.length < 5) return false;
4256
+ const upper = subject.replace(/[^A-Z]/g, "").length;
4257
+ return upper / letters.length > 0.5;
4258
+ }
4259
+
3004
4260
  // src/server/api/setup-status.ts
3005
4261
  async function runSetupChecks(mailer) {
3006
4262
  const checks = [];
@@ -3010,6 +4266,12 @@ async function runSetupChecks(mailer) {
3010
4266
  checks.push(await checkWorkersHeartbeat(mailer));
3011
4267
  }
3012
4268
  checks.push(await checkCircuitBreaker(mailer));
4269
+ checks.push(await checkDnsbl(mailer));
4270
+ checks.push(await checkPostmaster(mailer));
4271
+ checks.push(await checkSnds(mailer));
4272
+ const jmrp = checkJmrp(mailer);
4273
+ if (jmrp) checks.push(jmrp);
4274
+ checks.push(await checkDmarc(mailer));
3013
4275
  checks.push(...checkFromDefaultsAgainstRegistry(mailer));
3014
4276
  checks.push(...await checkPublishedTemplates(mailer));
3015
4277
  checks.push(await checkPostalAddress(mailer));
@@ -3059,7 +4321,10 @@ async function checkQueue(mailer) {
3059
4321
  }
3060
4322
  }
3061
4323
  async function checkWorkersHeartbeat(mailer) {
3062
- const h = await mailer.collections.health.findOne({ _id: "singleton" });
4324
+ const h = await mailer.collections.health.findOne(
4325
+ {},
4326
+ { sort: { updatedAt: -1 } }
4327
+ );
3063
4328
  const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
3064
4329
  const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
3065
4330
  if (!h) {
@@ -3089,25 +4354,264 @@ async function checkWorkersHeartbeat(mailer) {
3089
4354
  };
3090
4355
  }
3091
4356
  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" };
4357
+ const docs = await mailer.collections.health.find({}).toArray();
4358
+ const buckets = docs.filter((d) => d._id !== HEALTH_AGG_ID);
4359
+ if (buckets.length === 0) {
4360
+ return {
4361
+ name: "circuit_breaker",
4362
+ label: "Circuit breaker",
4363
+ severity: "ok",
4364
+ message: "no telemetry yet",
4365
+ 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."
4366
+ };
3095
4367
  }
3096
- if (h.status === "degraded") {
4368
+ const tripped = buckets.filter((d) => d.status === "tripped");
4369
+ if (tripped.length > 0) {
4370
+ const summary = tripped.map((d) => `${d.senderDomain ?? "_unknown"}/${d.kind}`).join(", ");
4371
+ return {
4372
+ name: "circuit_breaker",
4373
+ label: "Circuit breaker",
4374
+ severity: "error",
4375
+ message: `tripped: ${summary}`,
4376
+ 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)."
4377
+ };
4378
+ }
4379
+ const degraded = buckets.filter((d) => d.status === "degraded");
4380
+ if (degraded.length > 0) {
4381
+ const summary = degraded.map((d) => `${d.senderDomain ?? "_unknown"}/${d.kind}`).join(", ");
3097
4382
  return {
3098
4383
  name: "circuit_breaker",
3099
4384
  label: "Circuit breaker",
3100
4385
  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."
4386
+ message: `degraded: ${summary}`,
4387
+ 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."
4388
+ };
4389
+ }
4390
+ return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
4391
+ }
4392
+ async function checkDnsbl(mailer) {
4393
+ const docs = await mailer.collections.dnsblChecks.find({}).toArray();
4394
+ if (docs.length === 0) {
4395
+ return {
4396
+ name: "dnsbl",
4397
+ label: "DNS block-list checks",
4398
+ severity: "ok",
4399
+ message: "no checks run yet",
4400
+ hint: "Checks run automatically once per dnsbl.intervalHours (default 24h). To run now, POST /api/dnsbl/recheck."
4401
+ };
4402
+ }
4403
+ const listed = docs.filter((d) => d.result === "listed");
4404
+ if (listed.length > 0) {
4405
+ const summary = listed.map((d) => `${d.target} on ${d.listLabel}`).slice(0, 3).join("; ");
4406
+ const more = listed.length > 3 ? ` (+${listed.length - 3} more)` : "";
4407
+ return {
4408
+ name: "dnsbl",
4409
+ label: "DNS block-list checks",
4410
+ severity: "error",
4411
+ message: `${listed.length} listing(s): ${summary}${more}`,
4412
+ 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."
4413
+ };
4414
+ }
4415
+ const errored = docs.filter((d) => d.result === "error");
4416
+ if (errored.length === docs.length) {
4417
+ return {
4418
+ name: "dnsbl",
4419
+ label: "DNS block-list checks",
4420
+ severity: "warn",
4421
+ message: `all ${docs.length} checks errored`,
4422
+ 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
4423
  };
3104
4424
  }
3105
4425
  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."
4426
+ name: "dnsbl",
4427
+ label: "DNS block-list checks",
4428
+ severity: "ok",
4429
+ message: `clean (${docs.length} checks)`
4430
+ };
4431
+ }
4432
+ async function checkPostmaster(mailer) {
4433
+ const cfg = mailer.config.postmaster;
4434
+ if (!cfg?.clientId || !cfg?.clientSecret || !cfg?.refreshToken) {
4435
+ return {
4436
+ name: "postmaster",
4437
+ label: "Google Postmaster Tools",
4438
+ severity: "ok",
4439
+ message: "not configured (optional)",
4440
+ 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."
4441
+ };
4442
+ }
4443
+ const snapshots = await mailer.collections.postmasterSnapshots.find({}).sort({ fetchedAt: -1 }).toArray();
4444
+ if (snapshots.length === 0) {
4445
+ return {
4446
+ name: "postmaster",
4447
+ label: "Google Postmaster Tools",
4448
+ severity: "warn",
4449
+ message: "configured but no data yet",
4450
+ 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."
4451
+ };
4452
+ }
4453
+ const latestByDomain = /* @__PURE__ */ new Map();
4454
+ for (const s of snapshots) {
4455
+ if (!latestByDomain.has(s.domain)) latestByDomain.set(s.domain, s);
4456
+ }
4457
+ const bad = [];
4458
+ const low = [];
4459
+ for (const s of latestByDomain.values()) {
4460
+ if (s.domainReputation === "BAD") bad.push(s.domain);
4461
+ else if (s.domainReputation === "LOW") low.push(s.domain);
4462
+ }
4463
+ if (bad.length > 0) {
4464
+ return {
4465
+ name: "postmaster",
4466
+ label: "Google Postmaster Tools",
4467
+ severity: "error",
4468
+ message: `BAD reputation on ${bad.join(", ")}`,
4469
+ 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."
4470
+ };
4471
+ }
4472
+ if (low.length > 0) {
4473
+ return {
4474
+ name: "postmaster",
4475
+ label: "Google Postmaster Tools",
4476
+ severity: "warn",
4477
+ message: `LOW reputation on ${low.join(", ")}`,
4478
+ hint: "Reputation has slipped below Medium. Review recent complaint + bounce activity, slow down marketing volume, and re-engage your most active segment only."
4479
+ };
4480
+ }
4481
+ return {
4482
+ name: "postmaster",
4483
+ label: "Google Postmaster Tools",
4484
+ severity: "ok",
4485
+ // Includes REPUTATION_CATEGORY_UNSPECIFIED ("no data yet") domains —
4486
+ // surface that explicitly so the operator isn't misled.
4487
+ message: (() => {
4488
+ const tiers = new Set(Array.from(latestByDomain.values()).map((d) => d.domainReputation));
4489
+ const hasUnspec = tiers.has("REPUTATION_CATEGORY_UNSPECIFIED");
4490
+ return `${latestByDomain.size} domain(s) tracked${hasUnspec ? " (some have no reputation data yet)" : ", all High/Medium"}`;
4491
+ })()
4492
+ };
4493
+ }
4494
+ function checkJmrp(mailer) {
4495
+ const hasDedicatedIp = (mailer.config.dnsbl?.dedicatedIps?.length ?? 0) > 0;
4496
+ if (!hasDedicatedIp) return null;
4497
+ return {
4498
+ name: "snds_jmrp",
4499
+ label: "Microsoft JMRP enrolment",
4500
+ severity: "ok",
4501
+ message: "manual setup \u2014 confirm in your runbook",
4502
+ 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."
4503
+ };
4504
+ }
4505
+ async function checkSnds(mailer) {
4506
+ const cfg = mailer.config.snds;
4507
+ const hasDedicatedIp = (mailer.config.dnsbl?.dedicatedIps?.length ?? 0) > 0;
4508
+ if (!cfg?.accessKey) {
4509
+ if (!hasDedicatedIp) {
4510
+ return {
4511
+ name: "snds",
4512
+ label: "Microsoft SNDS",
4513
+ severity: "ok",
4514
+ message: "not applicable (no dedicated IP)",
4515
+ hint: "SNDS reports IP-level Outlook/Hotmail reputation. Only meaningful when you send from a dedicated IP. Skip unless you do."
4516
+ };
4517
+ }
4518
+ return {
4519
+ name: "snds",
4520
+ label: "Microsoft SNDS",
4521
+ severity: "warn",
4522
+ message: "dedicated IP configured but SNDS access key not set",
4523
+ 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."
4524
+ };
4525
+ }
4526
+ const snapshots = await mailer.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).toArray();
4527
+ if (snapshots.length === 0) {
4528
+ return {
4529
+ name: "snds",
4530
+ label: "Microsoft SNDS",
4531
+ severity: "warn",
4532
+ message: "configured but no data yet",
4533
+ 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."
4534
+ };
4535
+ }
4536
+ const latestByIp = /* @__PURE__ */ new Map();
4537
+ for (const s of snapshots) {
4538
+ if (!latestByIp.has(s.ip)) latestByIp.set(s.ip, s);
4539
+ }
4540
+ const red = [];
4541
+ const yellow = [];
4542
+ for (const s of latestByIp.values()) {
4543
+ if (s.filterResult === "RED") red.push(s.ip);
4544
+ else if (s.filterResult === "YELLOW") yellow.push(s.ip);
4545
+ }
4546
+ if (red.length > 0) {
4547
+ return {
4548
+ name: "snds",
4549
+ label: "Microsoft SNDS",
4550
+ severity: "error",
4551
+ message: `RED filter on ${red.join(", ")}`,
4552
+ 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)."
4553
+ };
4554
+ }
4555
+ if (yellow.length > 0) {
4556
+ return {
4557
+ name: "snds",
4558
+ label: "Microsoft SNDS",
4559
+ severity: "warn",
4560
+ message: `YELLOW filter on ${yellow.join(", ")}`,
4561
+ 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."
4562
+ };
4563
+ }
4564
+ return {
4565
+ name: "snds",
4566
+ label: "Microsoft SNDS",
4567
+ severity: "ok",
4568
+ message: `${latestByIp.size} IP(s) tracked, all GREEN`
4569
+ };
4570
+ }
4571
+ async function checkDmarc(mailer) {
4572
+ const reportCount = await mailer.collections.dmarcReports.estimatedDocumentCount();
4573
+ if (reportCount === 0) {
4574
+ return {
4575
+ name: "dmarc",
4576
+ label: "DMARC RUA reports",
4577
+ severity: "ok",
4578
+ message: "no reports ingested yet",
4579
+ 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."
4580
+ };
4581
+ }
4582
+ const since = new Date(Date.now() - 7 * 864e5);
4583
+ const recent = await mailer.collections.dmarcReports.find({ rangeEnd: { $gte: since } }).toArray();
4584
+ if (recent.length === 0) {
4585
+ return {
4586
+ name: "dmarc",
4587
+ label: "DMARC RUA reports",
4588
+ severity: "warn",
4589
+ message: `no reports in the last 7 days (${reportCount} total)`,
4590
+ 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."
4591
+ };
4592
+ }
4593
+ const tagged = await resolveSourceTags(mailer.getRunnerContext());
4594
+ const known = new Set(Array.from(tagged.keys()));
4595
+ const since30 = new Date(Date.now() - 30 * 864e5);
4596
+ const failures = await mailer.collections.dmarcFailures.find({ receivedAt: { $gte: since30 } }).toArray();
4597
+ const unknownFailing = failures.filter((f) => !known.has(f.sourceIp));
4598
+ if (unknownFailing.length === 0) {
4599
+ return {
4600
+ name: "dmarc",
4601
+ label: "DMARC RUA reports",
4602
+ severity: "ok",
4603
+ message: `${recent.length} report(s) this week, no failing unknown sources`
4604
+ };
4605
+ }
4606
+ const totalFailing = unknownFailing.reduce((acc, f) => acc + f.count, 0);
4607
+ const ips = Array.from(new Set(unknownFailing.map((f) => f.sourceIp))).slice(0, 3);
4608
+ const more = unknownFailing.length > 3 ? ` (+${unknownFailing.length - 3} more)` : "";
4609
+ return {
4610
+ name: "dmarc",
4611
+ label: "DMARC RUA reports",
4612
+ severity: "warn",
4613
+ message: `${totalFailing} failing message(s) from ${ips.join(", ")}${more}`,
4614
+ 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
4615
  };
3112
4616
  }
3113
4617
  function checkFromDefaultsAgainstRegistry(mailer) {
@@ -3213,11 +4717,291 @@ async function checkDoiTemplate(mailer) {
3213
4717
  hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
3214
4718
  };
3215
4719
  }
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`;
4720
+ function humanDuration(ms) {
4721
+ if (ms < 1e3) return `${ms}ms`;
4722
+ if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
4723
+ if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
4724
+ return `${Math.round(ms / 36e5)}h`;
4725
+ }
4726
+
4727
+ // src/server/runner/hygiene.ts
4728
+ var DAY_MS = 864e5;
4729
+ var HYGIENE_BUCKETS = [
4730
+ { label: "Engaged (last 30 days)", maxDays: 30 },
4731
+ { label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
4732
+ { label: "Engaged (61-90 days)", minDays: 60, maxDays: 90 },
4733
+ { label: "Engaged (91-180 days)", minDays: 90, maxDays: 180 },
4734
+ { label: "Inactive (>180 days)", minDays: 180 },
4735
+ { label: "Never engaged", neverEngaged: true }
4736
+ ];
4737
+ async function computeListHygiene(ctx, opts = {}) {
4738
+ const now = opts.now ?? Date.now();
4739
+ const sunsetDays = opts.sunsetThresholdDays ?? 180;
4740
+ const recentDays = opts.recentWindowDays ?? 90;
4741
+ const recentCutoff = new Date(now - recentDays * DAY_MS);
4742
+ const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
4743
+ const subscribedIds = /* @__PURE__ */ new Set();
4744
+ {
4745
+ const cursor2 = ctx.collections.subscriptions.find({ status: "subscribed" }, { projection: { externalId: 1 } });
4746
+ for await (const s of cursor2) subscribedIds.add(s.externalId);
4747
+ }
4748
+ const cursor = ctx.collections.sends.aggregate(
4749
+ [
4750
+ {
4751
+ $group: {
4752
+ _id: "$externalId",
4753
+ lastOpened: { $max: "$openedAt" },
4754
+ lastClicked: { $max: "$firstClickAt" },
4755
+ firstSent: { $min: "$queuedAt" },
4756
+ totalSends: { $sum: 1 },
4757
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
4758
+ hardBounced: {
4759
+ $sum: {
4760
+ $cond: [
4761
+ { $and: [{ $eq: ["$status", "bounced"] }, { $eq: ["$bounceType", "hard"] }] },
4762
+ 1,
4763
+ 0
4764
+ ]
4765
+ }
4766
+ },
4767
+ complained: { $sum: { $cond: [{ $ne: ["$complainedAt", null] }, 1, 0] } },
4768
+ recentSends: { $sum: { $cond: [{ $gte: ["$queuedAt", recentCutoff] }, 1, 0] } },
4769
+ recentBounced: {
4770
+ $sum: {
4771
+ $cond: [
4772
+ { $and: [{ $eq: ["$status", "bounced"] }, { $gte: ["$queuedAt", recentCutoff] }] },
4773
+ 1,
4774
+ 0
4775
+ ]
4776
+ }
4777
+ },
4778
+ recentComplained: {
4779
+ $sum: {
4780
+ $cond: [
4781
+ { $and: [{ $ne: ["$complainedAt", null] }, { $gte: ["$queuedAt", recentCutoff] }] },
4782
+ 1,
4783
+ 0
4784
+ ]
4785
+ }
4786
+ }
4787
+ }
4788
+ }
4789
+ ],
4790
+ { allowDiskUse: true }
4791
+ );
4792
+ const buckets = HYGIENE_BUCKETS.map((b) => ({
4793
+ label: b.label,
4794
+ minDays: "minDays" in b ? b.minDays : null,
4795
+ maxDays: "maxDays" in b ? b.maxDays : null,
4796
+ neverEngaged: "neverEngaged" in b && b.neverEngaged,
4797
+ count: 0,
4798
+ pctOfTotal: 0
4799
+ }));
4800
+ let totalWithSends = 0;
4801
+ let sunsetCount = 0;
4802
+ const cohortLifetimeAcc = { totalSends: 0, bounced: 0, hardBounced: 0, complained: 0 };
4803
+ const cohortRecentAcc = { totalSends: 0, bounced: 0, complained: 0 };
4804
+ for await (const row of cursor) {
4805
+ if (!subscribedIds.has(row._id)) continue;
4806
+ totalWithSends++;
4807
+ const lastOpened = row.lastOpened ? new Date(row.lastOpened).getTime() : null;
4808
+ const lastClicked = row.lastClicked ? new Date(row.lastClicked).getTime() : null;
4809
+ const lastEngaged = lastOpened == null && lastClicked == null ? null : Math.max(lastOpened ?? 0, lastClicked ?? 0);
4810
+ let isSunset = false;
4811
+ if (lastEngaged == null) {
4812
+ const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
4813
+ buckets[5].count++;
4814
+ if (firstSent != null && now - firstSent > sunsetDays * DAY_MS) {
4815
+ isSunset = true;
4816
+ }
4817
+ } else {
4818
+ const daysSince = (now - lastEngaged) / DAY_MS;
4819
+ if (daysSince <= 30) buckets[0].count++;
4820
+ else if (daysSince <= 60) buckets[1].count++;
4821
+ else if (daysSince <= 90) buckets[2].count++;
4822
+ else if (daysSince <= 180) buckets[3].count++;
4823
+ else {
4824
+ buckets[4].count++;
4825
+ isSunset = true;
4826
+ }
4827
+ }
4828
+ if (isSunset) {
4829
+ sunsetCount++;
4830
+ cohortLifetimeAcc.totalSends += row.totalSends;
4831
+ cohortLifetimeAcc.bounced += row.bounced;
4832
+ cohortLifetimeAcc.hardBounced += row.hardBounced;
4833
+ cohortLifetimeAcc.complained += row.complained;
4834
+ cohortRecentAcc.totalSends += row.recentSends;
4835
+ cohortRecentAcc.bounced += row.recentBounced;
4836
+ cohortRecentAcc.complained += row.recentComplained;
4837
+ }
4838
+ }
4839
+ for (const b of buckets) {
4840
+ b.pctOfTotal = totalSubscribers === 0 ? 0 : b.count / totalSubscribers;
4841
+ }
4842
+ const cohortLifetime = cohortLifetimeAcc.totalSends > 0 ? {
4843
+ totalSends: cohortLifetimeAcc.totalSends,
4844
+ bouncedRate: cohortLifetimeAcc.bounced / cohortLifetimeAcc.totalSends,
4845
+ hardBouncedRate: cohortLifetimeAcc.hardBounced / cohortLifetimeAcc.totalSends,
4846
+ complaintRate: cohortLifetimeAcc.complained / cohortLifetimeAcc.totalSends
4847
+ } : null;
4848
+ let projectedImpact = null;
4849
+ const recentAll = await ctx.collections.sends.aggregate([
4850
+ { $match: { queuedAt: { $gte: recentCutoff } } },
4851
+ {
4852
+ $group: {
4853
+ _id: null,
4854
+ totalSends: { $sum: 1 },
4855
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
4856
+ complained: { $sum: { $cond: [{ $ne: ["$complainedAt", null] }, 1, 0] } }
4857
+ }
4858
+ }
4859
+ ]).toArray();
4860
+ if (recentAll[0] && recentAll[0].totalSends > 0) {
4861
+ const all = recentAll[0];
4862
+ const cohort = cohortRecentAcc;
4863
+ const currentBounceRate = all.bounced / all.totalSends;
4864
+ const currentComplaintRate = all.complained / all.totalSends;
4865
+ const remainingSends = all.totalSends - cohort.totalSends;
4866
+ const remainingBounces = all.bounced - cohort.bounced;
4867
+ const remainingComplaints = all.complained - cohort.complained;
4868
+ const projectedBounceRate = remainingSends > 0 ? remainingBounces / remainingSends : 0;
4869
+ const projectedComplaintRate = remainingSends > 0 ? remainingComplaints / remainingSends : 0;
4870
+ projectedImpact = {
4871
+ recentTotalSends: all.totalSends,
4872
+ recentBounced: all.bounced,
4873
+ recentComplained: all.complained,
4874
+ cohortRecentSends: cohort.totalSends,
4875
+ cohortRecentBounced: cohort.bounced,
4876
+ cohortRecentComplained: cohort.complained,
4877
+ currentOverallBounceRate: currentBounceRate,
4878
+ projectedBounceRate,
4879
+ currentOverallComplaintRate: currentComplaintRate,
4880
+ projectedComplaintRate
4881
+ };
4882
+ }
4883
+ return {
4884
+ totalSubscribers,
4885
+ totalWithSends,
4886
+ buckets,
4887
+ sunsetCandidate: {
4888
+ count: sunsetCount,
4889
+ pctOfTotal: totalSubscribers === 0 ? 0 : sunsetCount / totalSubscribers,
4890
+ cohortLifetime,
4891
+ projectedImpact
4892
+ },
4893
+ computedAt: new Date(now).toISOString()
4894
+ };
4895
+ }
4896
+ var FETCH_TIMEOUT_MS3 = 15e3;
4897
+ function createMailTesterClient(cfg, fetcher = globalThis.fetch) {
4898
+ const base = cfg.baseUrl ?? "https://mail-tester.com/api";
4899
+ function sanitize(s) {
4900
+ if (!cfg.apiKey) return s;
4901
+ return s.split(cfg.apiKey).join("<redacted>").split(encodeURIComponent(cfg.apiKey)).join("<redacted>");
4902
+ }
4903
+ async function call(method, path3, body) {
4904
+ const ctl = new AbortController();
4905
+ const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS3);
4906
+ let res;
4907
+ try {
4908
+ res = await fetcher(`${base}/${encodeURIComponent(cfg.apiKey)}${path3}`, {
4909
+ method,
4910
+ headers: { "content-type": "application/json" },
4911
+ body: body ? JSON.stringify(body) : void 0,
4912
+ signal: ctl.signal
4913
+ });
4914
+ } catch (err) {
4915
+ throw new Error(`Mail-Tester ${method} ${sanitize(path3)} failed: ${sanitize(String(err?.message ?? err))}`);
4916
+ } finally {
4917
+ clearTimeout(t);
4918
+ }
4919
+ if (!res.ok) {
4920
+ const text = await safeText3(res);
4921
+ throw new Error(`Mail-Tester ${method} ${sanitize(path3)} \u2192 ${res.status}: ${sanitize(text)}`);
4922
+ }
4923
+ return res.json();
4924
+ }
4925
+ return {
4926
+ async provisionCheck() {
4927
+ const data = await call("POST", "/new");
4928
+ const checkId = String(data.check_id ?? data.checkId ?? "");
4929
+ const emailAddress = String(data.email_address ?? data.email ?? "");
4930
+ if (!checkId || !emailAddress) throw new Error("Mail-Tester /new returned an unexpected shape");
4931
+ return { checkId, emailAddress };
4932
+ },
4933
+ async fetchResult(checkId) {
4934
+ const data = await call("GET", `/${encodeURIComponent(checkId)}`);
4935
+ const ready = Boolean(data.ready ?? data.complete ?? data.score != null);
4936
+ const scoreRaw = Number(data.score);
4937
+ const score = Number.isFinite(scoreRaw) ? scoreRaw : 0;
4938
+ const feedback = Array.isArray(data.feedback) ? data.feedback.map(normalizeFeedback) : [];
4939
+ const rawSummary = typeof data.summary === "string" ? data.summary : null;
4940
+ return { ready: ready && Number.isFinite(scoreRaw), score, feedback, rawSummary };
4941
+ }
4942
+ };
4943
+ }
4944
+ function normalizeFeedback(raw) {
4945
+ const severity = String(raw.severity ?? "info").toLowerCase();
4946
+ return {
4947
+ category: String(raw.category ?? "other"),
4948
+ severity: severity === "error" || severity === "warning" || severity === "info" ? severity : "info",
4949
+ message: String(raw.message ?? "")
4950
+ };
4951
+ }
4952
+ async function safeText3(res) {
4953
+ try {
4954
+ return await res.text();
4955
+ } catch {
4956
+ return "<no body>";
4957
+ }
4958
+ }
4959
+ function mailTesterContentKey(input) {
4960
+ return crypto2.createHash("sha256").update(`${input.bodyHash}|${input.subject}|${input.fromEmail}`).digest("hex");
4961
+ }
4962
+ async function findCachedScore(ctx, contentKey) {
4963
+ const doc = await ctx.collections.mailTesterScores.findOne({ contentKey });
4964
+ if (!doc) return null;
4965
+ if (new Date(doc.expiresAt).getTime() < Date.now()) return null;
4966
+ return doc;
4967
+ }
4968
+ async function persistScore(ctx, input) {
4969
+ const cfg = ctx.config.mailTester;
4970
+ const hours = cfg?.cacheHours ?? 24;
4971
+ const now = /* @__PURE__ */ new Date();
4972
+ const expiresAt = new Date(now.getTime() + hours * 60 * 60 * 1e3);
4973
+ const doc = {
4974
+ templateSlug: input.templateSlug,
4975
+ contentKey: input.contentKey,
4976
+ checkId: input.checkId,
4977
+ score: input.result.score,
4978
+ feedback: input.result.feedback,
4979
+ rawSummary: input.result.rawSummary,
4980
+ fetchedAt: now,
4981
+ expiresAt
4982
+ };
4983
+ await ctx.collections.mailTesterScores.updateOne(
4984
+ { contentKey: input.contentKey },
4985
+ { $set: doc },
4986
+ { upsert: true }
4987
+ );
4988
+ return doc;
4989
+ }
4990
+ async function evaluateMailTesterGate(ctx, input) {
4991
+ const cfg = ctx.config.mailTester;
4992
+ if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
4993
+ const minScore = cfg.minScore ?? 8;
4994
+ const key = mailTesterContentKey(input);
4995
+ const cached = await findCachedScore(ctx, key);
4996
+ if (!cached) return { allowed: true, reason: null, score: null };
4997
+ if (cached.score < minScore) {
4998
+ return {
4999
+ allowed: false,
5000
+ reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
5001
+ score: cached
5002
+ };
5003
+ }
5004
+ return { allowed: true, reason: null, score: cached };
3221
5005
  }
3222
5006
 
3223
5007
  // src/server/api/admin.ts
@@ -3243,35 +5027,41 @@ function createAdminRouter(mailer, opts = {}) {
3243
5027
  req.actor = getActor(req);
3244
5028
  next();
3245
5029
  });
3246
- router.use("/api", apiRouter(mailer));
5030
+ router.use("/api", apiRouter(mailer, opts));
3247
5031
  router.get(/.*/, (_req, res) => {
3248
5032
  res.sendFile(path.join(spaDir, "index.html"));
3249
5033
  });
3250
5034
  return router;
3251
5035
  }
3252
- function apiRouter(mailer) {
5036
+ function apiRouter(mailer, opts = {}) {
3253
5037
  const r = Router();
3254
5038
  const c = mailer.collections;
5039
+ function getMailTesterClient() {
5040
+ if (opts.mailTesterClient) return opts.mailTesterClient;
5041
+ const cfg = mailer.config.mailTester;
5042
+ if (!cfg?.apiKey) return null;
5043
+ return createMailTesterClient(cfg);
5044
+ }
3255
5045
  r.get(
3256
5046
  "/me",
3257
5047
  asyncHandler(async (req, res) => {
3258
- const [flows, templates, broadcasts, contacts, suppressions, health] = await Promise.all([
5048
+ const [flows, templates, broadcasts, contacts, suppressions, healthDocs] = await Promise.all([
3259
5049
  c.flows.estimatedDocumentCount(),
3260
5050
  c.templates.estimatedDocumentCount(),
3261
5051
  c.broadcasts.estimatedDocumentCount(),
3262
5052
  c.subscriptions.countDocuments({ status: "subscribed" }),
3263
5053
  c.suppressions.estimatedDocumentCount(),
3264
- c.health.findOne({ _id: "singleton" })
5054
+ c.health.find({}).limit(500).toArray()
3265
5055
  ]);
3266
5056
  res.json({
3267
5057
  actor: req.actor,
3268
- permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true },
3269
5058
  counts: { flows, templates, broadcasts, contacts, suppressions },
3270
- health: { status: health?.status ?? "healthy" },
5059
+ health: { status: effectiveOverallStatus(healthDocs) },
3271
5060
  providers: {
3272
5061
  names: Object.keys(mailer.config.providers),
3273
5062
  default: mailer.config.defaultProvider
3274
- }
5063
+ },
5064
+ broadcastConfirmationThreshold: mailer.config.broadcastConfirmationThreshold
3275
5065
  });
3276
5066
  })
3277
5067
  );
@@ -3333,8 +5123,11 @@ function apiRouter(mailer) {
3333
5123
  if (prevD === 0 || curD === 0) return null;
3334
5124
  return curN / curD - prevN / prevD;
3335
5125
  };
3336
- const health = await c.health.findOne({ _id: "singleton" });
3337
- const recentFlows = await c.flows.find({ enabled: true }).limit(5).toArray();
5126
+ const healthDocs = await c.health.find({}).limit(500).toArray();
5127
+ const healthAgg = healthDocs.find((d) => d._id === HEALTH_AGG_ID) ?? null;
5128
+ const recentFlowsRaw = await c.flows.find({ enabled: true }).limit(5).toArray();
5129
+ const flowStatsMap = await computeFlowStats(mailer);
5130
+ const recentFlows = recentFlowsRaw.map((f) => ({ ...f, stats: flowStatsMap.get(f.slug) ?? emptyFlowStats() }));
3338
5131
  const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
3339
5132
  const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
3340
5133
  const queueCounts = await collectQueueCounts(mailer);
@@ -3351,8 +5144,7 @@ function apiRouter(mailer) {
3351
5144
  },
3352
5145
  openRate: {
3353
5146
  value: sentTotal === 0 ? null : openedCount / sentTotal,
3354
- delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev),
3355
- exclBots: false
5147
+ delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev)
3356
5148
  },
3357
5149
  clickRate: {
3358
5150
  value: sentTotal === 0 ? null : clickedCount / sentTotal,
@@ -3360,7 +5152,16 @@ function apiRouter(mailer) {
3360
5152
  }
3361
5153
  },
3362
5154
  series: { hourly: { sends: sendSeries, opens: openSeries } },
3363
- health: health ? { status: health.status, rates: health.rates } : { status: "healthy", rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 } },
5155
+ health: {
5156
+ status: effectiveOverallStatus(healthDocs),
5157
+ rates: healthAgg?.rates ?? null,
5158
+ thresholds: {
5159
+ hardBounceRatePctTrip: mailer.config.circuitBreaker.hardBounceRatePctTrip,
5160
+ complaintRatePctTrip: mailer.config.circuitBreaker.complaintRatePctTrip,
5161
+ combinedBounceRatePctTrip: mailer.config.circuitBreaker.combinedBounceRatePctTrip,
5162
+ failedToSendRatePctDegrade: mailer.config.circuitBreaker.failedToSendRatePctDegrade
5163
+ }
5164
+ },
3364
5165
  queue: {
3365
5166
  inFlight: queueCounts?.inFlight ?? null,
3366
5167
  delayed: queueCounts?.delayed ?? null,
@@ -3373,11 +5174,25 @@ function apiRouter(mailer) {
3373
5174
  });
3374
5175
  })
3375
5176
  );
5177
+ r.get(
5178
+ "/events",
5179
+ asyncHandler(async (_req, res) => {
5180
+ const registered = mailer.events.list();
5181
+ const seenNames = await c.events.distinct("name");
5182
+ const known = new Set(registered.map((r2) => r2.name));
5183
+ const unregistered = seenNames.filter((n) => n && !known.has(n));
5184
+ res.json({
5185
+ registered: registered.sort((a, b) => a.name.localeCompare(b.name)),
5186
+ seen: unregistered.sort()
5187
+ });
5188
+ })
5189
+ );
3376
5190
  r.get(
3377
5191
  "/flows",
3378
5192
  asyncHandler(async (_req, res) => {
3379
5193
  const flows = await c.flows.find().sort({ updatedAt: -1 }).toArray();
3380
- res.json(flows);
5194
+ const stats = await computeFlowStats(mailer);
5195
+ res.json(flows.map((f) => ({ ...f, stats: stats.get(f.slug) ?? emptyFlowStats() })));
3381
5196
  })
3382
5197
  );
3383
5198
  r.get(
@@ -3385,7 +5200,8 @@ function apiRouter(mailer) {
3385
5200
  asyncHandler(async (req, res) => {
3386
5201
  const flow = await c.flows.findOne({ slug: req.params.slug });
3387
5202
  if (!flow) return res.status(404).json({ error: "not_found" });
3388
- return res.json(flow);
5203
+ const stats = (await computeFlowStats(mailer, flow.slug)).get(flow.slug) ?? emptyFlowStats();
5204
+ return res.json({ ...flow, stats });
3389
5205
  })
3390
5206
  );
3391
5207
  r.post(
@@ -3420,7 +5236,8 @@ function apiRouter(mailer) {
3420
5236
  "/templates",
3421
5237
  asyncHandler(async (_req, res) => {
3422
5238
  const templates = await c.templates.find().sort({ updatedAt: -1 }).toArray();
3423
- res.json(templates);
5239
+ const stats = await computeTemplateStats(mailer);
5240
+ res.json(templates.map((t) => ({ ...t, stats: stats.get(t.slug) ?? emptyTemplateStats() })));
3424
5241
  })
3425
5242
  );
3426
5243
  r.get(
@@ -3428,14 +5245,16 @@ function apiRouter(mailer) {
3428
5245
  asyncHandler(async (req, res) => {
3429
5246
  const template = await c.templates.findOne({ slug: req.params.slug });
3430
5247
  if (!template) return res.status(404).json({ error: "not_found" });
3431
- return res.json(template);
5248
+ const stats = (await computeTemplateStats(mailer, template.slug)).get(template.slug) ?? emptyTemplateStats();
5249
+ return res.json({ ...template, stats });
3432
5250
  })
3433
5251
  );
3434
5252
  r.get(
3435
5253
  "/broadcasts",
3436
5254
  asyncHandler(async (_req, res) => {
3437
5255
  const broadcasts = await c.broadcasts.find().sort({ createdAt: -1 }).toArray();
3438
- res.json(broadcasts);
5256
+ const stats = await computeBroadcastStats(mailer);
5257
+ res.json(broadcasts.map((b) => ({ ...b, stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
3439
5258
  })
3440
5259
  );
3441
5260
  r.get(
@@ -3443,7 +5262,8 @@ function apiRouter(mailer) {
3443
5262
  asyncHandler(async (req, res) => {
3444
5263
  const broadcast = await c.broadcasts.findOne({ slug: req.params.slug });
3445
5264
  if (!broadcast) return res.status(404).json({ error: "not_found" });
3446
- return res.json(broadcast);
5265
+ const stats = (await computeBroadcastStats(mailer, broadcast._id)).get(String(broadcast._id)) ?? emptyBroadcastStats();
5266
+ return res.json({ ...broadcast, stats });
3447
5267
  })
3448
5268
  );
3449
5269
  r.get(
@@ -3451,8 +5271,20 @@ function apiRouter(mailer) {
3451
5271
  asyncHandler(async (req, res) => {
3452
5272
  const cursor = typeof req.query.cursor === "string" ? req.query.cursor : void 0;
3453
5273
  const limit = Math.min(Number(req.query.limit ?? 50), 200);
3454
- const { contacts, nextCursor } = await mailer.adapter.query({}, { limit, cursor });
3455
- res.json({ contacts, nextCursor });
5274
+ const [{ contacts, nextCursor }, counts] = await Promise.all([
5275
+ mailer.adapter.query({}, { limit, cursor }),
5276
+ (async () => {
5277
+ const rows = await c.subscriptions.aggregate([{ $group: { _id: "$status", n: { $sum: 1 } } }]).toArray();
5278
+ const out = { subscribed: 0, pending_doi: 0, unsubscribed: 0, bounced: 0, complained: 0 };
5279
+ let total = 0;
5280
+ for (const r2 of rows) {
5281
+ if (r2._id) out[r2._id] = r2.n;
5282
+ total += r2.n;
5283
+ }
5284
+ return { ...out, total };
5285
+ })()
5286
+ ]);
5287
+ res.json({ contacts, nextCursor, counts });
3456
5288
  })
3457
5289
  );
3458
5290
  r.get(
@@ -3464,7 +5296,7 @@ function apiRouter(mailer) {
3464
5296
  c.subscriptions.findOne({ externalId }),
3465
5297
  c.events.find({ externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
3466
5298
  c.sends.find({ externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
3467
- c.flowRuns.find({ externalId, status: "active" }).toArray()
5299
+ c.flowRuns.find({ externalId, status: "active" }).sort({ nextActionAt: 1 }).limit(50).toArray()
3468
5300
  ]);
3469
5301
  if (!contact) return res.status(404).json({ error: "not_found" });
3470
5302
  return res.json({ contact, subscription, recentEvents, recentSends, activeRuns });
@@ -3488,7 +5320,7 @@ function apiRouter(mailer) {
3488
5320
  if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
3489
5321
  const send = await c.sends.findOne({ _id: new ObjectId(id) });
3490
5322
  if (!send) return res.status(404).json({ error: "not_found" });
3491
- const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).toArray() : [];
5323
+ const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).sort({ receivedAt: -1 }).limit(100).toArray() : [];
3492
5324
  return res.json({ send, webhookEvents: events });
3493
5325
  })
3494
5326
  );
@@ -3530,17 +5362,29 @@ function apiRouter(mailer) {
3530
5362
  r.get(
3531
5363
  "/health",
3532
5364
  asyncHandler(async (_req, res) => {
3533
- const h = await c.health.findOne({ _id: "singleton" });
3534
- res.json(
3535
- h ?? {
3536
- _id: "singleton",
3537
- status: "healthy",
3538
- windowStartedAt: new Date(Date.now() - 60 * 60 * 1e3),
3539
- windowDurationMs: 60 * 60 * 1e3,
3540
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3541
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3542
- }
3543
- );
5365
+ const cb = mailer.config.circuitBreaker;
5366
+ const thresholds = {
5367
+ hardBounceRatePctTrip: cb.hardBounceRatePctTrip,
5368
+ complaintRatePctTrip: cb.complaintRatePctTrip,
5369
+ combinedBounceRatePctTrip: cb.combinedBounceRatePctTrip,
5370
+ failedToSendRatePctDegrade: cb.failedToSendRatePctDegrade
5371
+ };
5372
+ const docs = await c.health.find({}).limit(500).toArray();
5373
+ const aggregate = docs.find((d) => d._id === HEALTH_AGG_ID) ?? null;
5374
+ const buckets = docs.filter((d) => d._id !== HEALTH_AGG_ID);
5375
+ const overall = effectiveOverallStatus(docs);
5376
+ if (docs.length === 0) {
5377
+ res.json({ status: null, rates: null, counters: null, aggregate: null, buckets: [], thresholds });
5378
+ return;
5379
+ }
5380
+ res.json({
5381
+ status: overall,
5382
+ rates: aggregate?.rates ?? null,
5383
+ counters: aggregate?.counters ?? null,
5384
+ aggregate,
5385
+ buckets,
5386
+ thresholds
5387
+ });
3544
5388
  })
3545
5389
  );
3546
5390
  r.get(
@@ -3553,18 +5397,330 @@ function apiRouter(mailer) {
3553
5397
  r.post(
3554
5398
  "/health/resume",
3555
5399
  asyncHandler(async (req, res) => {
3556
- await c.health.updateOne(
3557
- { _id: "singleton" },
5400
+ const body = req.body ?? {};
5401
+ if (body.senderDomain != null !== (body.kind != null)) {
5402
+ return res.status(400).json({
5403
+ error: "validation_failed",
5404
+ message: "senderDomain and kind must both be provided to target a single bucket, or both omitted to resume all tripped buckets."
5405
+ });
5406
+ }
5407
+ if (body.kind && !["marketing", "transactional"].includes(body.kind)) {
5408
+ return res.status(400).json({ error: "validation_failed", message: 'kind must be "marketing" or "transactional"' });
5409
+ }
5410
+ const target = body.senderDomain && body.kind ? { _id: healthBucketId(body.senderDomain.toLowerCase(), body.kind) } : { status: "tripped", _id: { $ne: HEALTH_AGG_ID } };
5411
+ const result = await c.health.updateMany(
5412
+ target,
3558
5413
  { $set: { status: "healthy", manuallyResumedAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() } }
3559
5414
  );
3560
5415
  await mailer.audit({
3561
5416
  actor: req.actor,
3562
5417
  action: "health.resume",
3563
- resource: { collection: "mailer_health" }
5418
+ resource: {
5419
+ collection: "mailer_health",
5420
+ id: body.senderDomain && body.kind ? `${body.senderDomain}|${body.kind}` : "all-tripped"
5421
+ }
5422
+ });
5423
+ res.json({ ok: true, resumed: result.modifiedCount });
5424
+ })
5425
+ );
5426
+ r.get(
5427
+ "/dnsbl",
5428
+ asyncHandler(async (_req, res) => {
5429
+ const checks = await c.dnsblChecks.find({}).sort({ result: 1, target: 1, list: 1 }).limit(500).toArray();
5430
+ const latestRunAt = checks.reduce((acc, d) => {
5431
+ const t = new Date(d.runAt);
5432
+ return acc && acc.getTime() >= t.getTime() ? acc : t;
5433
+ }, null);
5434
+ res.json({
5435
+ checks,
5436
+ latestRunAt: latestRunAt ? latestRunAt.toISOString() : null,
5437
+ intervalHours: mailer.config.dnsbl?.intervalHours ?? 24
5438
+ });
5439
+ })
5440
+ );
5441
+ r.post(
5442
+ "/dnsbl/recheck",
5443
+ asyncHandler(async (req, res) => {
5444
+ const result = await runDnsblChecks(mailer.getRunnerContext(), { force: true });
5445
+ await mailer.audit({
5446
+ actor: req.actor,
5447
+ action: "dnsbl.recheck",
5448
+ resource: { collection: "mailer_dnsbl_checks" },
5449
+ diffSummary: result.ran ? `${result.totalChecks} checks, ${result.listedCount} listed` : `not run: ${result.reason}`
5450
+ });
5451
+ res.json(result);
5452
+ })
5453
+ );
5454
+ r.get(
5455
+ "/postmaster",
5456
+ asyncHandler(async (_req, res) => {
5457
+ const cfg = mailer.config.postmaster;
5458
+ const configured = !!cfg?.clientId && !!cfg?.clientSecret && !!cfg?.refreshToken;
5459
+ const all = await c.postmasterSnapshots.find({}).sort({ domain: 1, date: -1 }).limit(2e3).toArray();
5460
+ const byDomain = /* @__PURE__ */ new Map();
5461
+ for (const s of all) {
5462
+ const arr = byDomain.get(s.domain) ?? [];
5463
+ if (arr.length < 30) arr.push(s);
5464
+ byDomain.set(s.domain, arr);
5465
+ }
5466
+ const domains = Array.from(byDomain.entries()).map(([domain, snapshots]) => ({
5467
+ domain,
5468
+ latest: snapshots[0] ?? null,
5469
+ history: snapshots
5470
+ }));
5471
+ res.json({
5472
+ configured,
5473
+ intervalHours: cfg?.intervalHours ?? 24,
5474
+ domains
5475
+ });
5476
+ })
5477
+ );
5478
+ r.post(
5479
+ "/postmaster/refresh",
5480
+ asyncHandler(async (req, res) => {
5481
+ const result = await runPostmasterPull(mailer.getRunnerContext(), { force: true });
5482
+ await mailer.audit({
5483
+ actor: req.actor,
5484
+ action: "postmaster.refresh",
5485
+ resource: { collection: "mailer_postmaster_snapshots" },
5486
+ diffSummary: result.ran ? `fetched ${result.fetched ?? 0}, tripped ${(result.trippedDomains ?? []).length}` : `not run: ${result.reason}`
5487
+ });
5488
+ res.json(result);
5489
+ })
5490
+ );
5491
+ let hygieneCache = null;
5492
+ let hygieneInFlight = null;
5493
+ const HYGIENE_CACHE_MS = 6e4;
5494
+ r.get(
5495
+ "/hygiene",
5496
+ asyncHandler(async (req, res) => {
5497
+ const force = req.query.refresh === "1" || req.query.refresh === "true";
5498
+ const now = Date.now();
5499
+ if (!force && hygieneCache && now - hygieneCache.computedAt < HYGIENE_CACHE_MS) {
5500
+ return res.json(hygieneCache.report);
5501
+ }
5502
+ if (!hygieneInFlight) {
5503
+ hygieneInFlight = (async () => {
5504
+ try {
5505
+ const report2 = await computeListHygiene(mailer.getRunnerContext());
5506
+ hygieneCache = { computedAt: Date.now(), report: report2 };
5507
+ return report2;
5508
+ } finally {
5509
+ hygieneInFlight = null;
5510
+ }
5511
+ })();
5512
+ }
5513
+ const report = await hygieneInFlight;
5514
+ res.json(report);
5515
+ })
5516
+ );
5517
+ const dmarcUpload = multer({
5518
+ storage: multer.memoryStorage(),
5519
+ limits: { fileSize: 10 * 1024 * 1024 }
5520
+ // 10 MB; reports are typically <100KB
5521
+ });
5522
+ r.post(
5523
+ "/dmarc/upload",
5524
+ dmarcUpload.single("file"),
5525
+ asyncHandler(async (req, res) => {
5526
+ const file = req.file;
5527
+ if (!file) return res.status(400).json({ error: "no_file", message: 'expected a "file" field' });
5528
+ try {
5529
+ const result = await ingestDmarcAttachment(mailer.getRunnerContext(), file.buffer, file.originalname);
5530
+ await mailer.audit({
5531
+ actor: req.actor,
5532
+ action: "dmarc.ingest",
5533
+ resource: { collection: "mailer_dmarc_reports", id: result.reportId },
5534
+ diffSummary: result.duplicate ? `duplicate report ${result.reportId}` : `ingested ${result.totalMessages} msgs (${result.passCount} pass / ${result.failCount} fail) for ${result.domain}`
5535
+ });
5536
+ return res.json(result);
5537
+ } catch (err) {
5538
+ return res.status(400).json({ error: "ingest_failed", message: String(err?.message ?? err) });
5539
+ }
5540
+ })
5541
+ );
5542
+ r.get(
5543
+ "/dmarc",
5544
+ asyncHandler(async (_req, res) => {
5545
+ const ctx = mailer.getRunnerContext();
5546
+ const tagged = await resolveSourceTags(ctx);
5547
+ const reports = await c.dmarcReports.find({}).sort({ rangeEnd: -1 }).limit(200).toArray();
5548
+ const since30 = new Date(Date.now() - 30 * 864e5);
5549
+ const recentFailures = await c.dmarcFailures.find({ receivedAt: { $gte: since30 } }).sort({ receivedAt: -1 }).limit(5e3).toArray();
5550
+ const byDomain = /* @__PURE__ */ new Map();
5551
+ for (const r2 of reports) {
5552
+ const cur = byDomain.get(r2.domain) ?? { passCount: 0, failCount: 0, totalMessages: 0, reportCount: 0, latestRangeEnd: null, latestPolicy: null, latestPct: null, reports: [] };
5553
+ cur.passCount += r2.passCount;
5554
+ cur.failCount += r2.failCount;
5555
+ cur.totalMessages += r2.totalMessages;
5556
+ cur.reportCount += 1;
5557
+ const re = new Date(r2.rangeEnd);
5558
+ if (!cur.latestRangeEnd || re > cur.latestRangeEnd) {
5559
+ cur.latestRangeEnd = re;
5560
+ cur.latestPolicy = r2.policyP;
5561
+ cur.latestPct = r2.policyPct;
5562
+ }
5563
+ cur.reports.push(r2);
5564
+ byDomain.set(r2.domain, cur);
5565
+ }
5566
+ const since14 = Date.now() - 14 * 864e5;
5567
+ const domains = Array.from(byDomain.entries()).map(([domain, s]) => {
5568
+ const dayTotals = /* @__PURE__ */ new Map();
5569
+ for (const r2 of s.reports) {
5570
+ if (new Date(r2.rangeEnd).getTime() < since14) continue;
5571
+ const day = new Date(r2.rangeEnd).toISOString().slice(0, 10);
5572
+ const d = dayTotals.get(day) ?? { pass: 0, total: 0 };
5573
+ d.pass += r2.passCount;
5574
+ d.total += r2.passCount + r2.failCount;
5575
+ dayTotals.set(day, d);
5576
+ }
5577
+ 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 }));
5578
+ const knownIps = new Set(Array.from(tagged.values()).filter((t) => !t.ignored).map((t) => t.ip));
5579
+ const ignoredIps = new Set(Array.from(tagged.values()).filter((t) => t.ignored).map((t) => t.ip));
5580
+ const suggested = suggestPolicyProgression({
5581
+ reports: s.reports.map((r2) => ({ rangeEnd: new Date(r2.rangeEnd), passCount: r2.passCount, failCount: r2.failCount })),
5582
+ failures: recentFailures.filter((f) => f.domain === domain).map((f) => ({ sourceIp: f.sourceIp, count: f.count, receivedAt: new Date(f.receivedAt) })),
5583
+ knownSourceIps: knownIps,
5584
+ ignoredSourceIps: ignoredIps,
5585
+ currentPolicy: s.latestPolicy,
5586
+ currentPct: s.latestPct
5587
+ });
5588
+ return {
5589
+ domain,
5590
+ passCount: s.passCount,
5591
+ failCount: s.failCount,
5592
+ totalMessages: s.totalMessages,
5593
+ reportCount: s.reportCount,
5594
+ latestRangeEnd: s.latestRangeEnd,
5595
+ alignmentRate: s.totalMessages === 0 ? null : s.passCount / s.totalMessages,
5596
+ currentPolicy: s.latestPolicy,
5597
+ currentPct: s.latestPct,
5598
+ progression: suggested ? { ...suggested, current: { policy: s.latestPolicy, pct: s.latestPct } } : null,
5599
+ series
5600
+ };
5601
+ });
5602
+ const topFailures = await c.dmarcFailures.aggregate([
5603
+ { $match: { receivedAt: { $gte: since30 } } },
5604
+ {
5605
+ $group: {
5606
+ _id: { sourceIp: "$sourceIp", domain: "$domain" },
5607
+ total: { $sum: "$count" },
5608
+ days: { $addToSet: "$day" },
5609
+ lastSeen: { $max: "$receivedAt" },
5610
+ sample: { $first: "$$ROOT" }
5611
+ }
5612
+ },
5613
+ { $project: { sourceIp: "$_id.sourceIp", domain: "$_id.domain", total: 1, days: { $size: "$days" }, lastSeen: 1, sample: 1 } },
5614
+ { $sort: { total: -1 } },
5615
+ { $limit: 50 }
5616
+ ]).toArray();
5617
+ const sources = topFailures.map((f) => {
5618
+ const tag = tagged.get(f._id.sourceIp);
5619
+ return {
5620
+ sourceIp: f._id.sourceIp,
5621
+ domain: f._id.domain,
5622
+ totalMessages: f.total,
5623
+ daysSeen: f.days,
5624
+ lastSeen: f.lastSeen,
5625
+ dkimResult: f.sample.dkimResult,
5626
+ spfResult: f.sample.spfResult,
5627
+ dispositionApplied: f.sample.dispositionApplied,
5628
+ label: tag?.label ?? null,
5629
+ ignored: !!tag?.ignored,
5630
+ tagSource: tag?.source ?? null
5631
+ };
5632
+ });
5633
+ res.json({
5634
+ domains,
5635
+ sources,
5636
+ recentReports: reports.slice(0, 30),
5637
+ retentionDays: mailer.config.dmarc?.retentionDays ?? 90
5638
+ });
5639
+ })
5640
+ );
5641
+ r.put(
5642
+ "/dmarc/sources/:ip",
5643
+ asyncHandler(async (req, res) => {
5644
+ const ip = String(req.params.ip ?? "");
5645
+ if (!net.isIP(ip)) {
5646
+ return res.status(400).json({ error: "validation_failed", message: "invalid IP" });
5647
+ }
5648
+ const body = req.body ?? {};
5649
+ if (!body.label || typeof body.label !== "string") {
5650
+ return res.status(400).json({ error: "validation_failed", message: "label is required" });
5651
+ }
5652
+ const label = body.label.trim().slice(0, 200);
5653
+ if (!label) {
5654
+ return res.status(400).json({ error: "validation_failed", message: "label is required" });
5655
+ }
5656
+ const ignored = !!body.ignored;
5657
+ await c.dmarcSourceTags.updateOne(
5658
+ { ip },
5659
+ { $set: { ip, label, ignored, setBy: req.actor ?? "unknown", setAt: /* @__PURE__ */ new Date() } },
5660
+ { upsert: true }
5661
+ );
5662
+ await mailer.audit({
5663
+ actor: req.actor,
5664
+ action: "dmarc.source_tag.upsert",
5665
+ resource: { collection: "mailer_dmarc_source_tags", id: ip },
5666
+ diffSummary: `${ip} \u2192 "${label}"${ignored ? " (ignored)" : ""}`
3564
5667
  });
3565
5668
  res.json({ ok: true });
3566
5669
  })
3567
5670
  );
5671
+ r.delete(
5672
+ "/dmarc/sources/:ip",
5673
+ asyncHandler(async (req, res) => {
5674
+ const ip = String(req.params.ip ?? "");
5675
+ if (!net.isIP(ip)) {
5676
+ return res.status(400).json({ error: "validation_failed", message: "invalid IP" });
5677
+ }
5678
+ const result = await c.dmarcSourceTags.deleteOne({ ip });
5679
+ await mailer.audit({
5680
+ actor: req.actor,
5681
+ action: "dmarc.source_tag.delete",
5682
+ resource: { collection: "mailer_dmarc_source_tags", id: ip }
5683
+ });
5684
+ res.json({ ok: true, deleted: result.deletedCount });
5685
+ })
5686
+ );
5687
+ r.get(
5688
+ "/snds",
5689
+ asyncHandler(async (_req, res) => {
5690
+ const cfg = mailer.config.snds;
5691
+ const configured = !!cfg?.accessKey;
5692
+ const all = await c.sndsSnapshots.find({}).sort({ ip: 1, activityStart: -1 }).limit(2e3).toArray();
5693
+ const byIp = /* @__PURE__ */ new Map();
5694
+ for (const s of all) {
5695
+ const arr = byIp.get(s.ip) ?? [];
5696
+ if (arr.length < 30) arr.push(s);
5697
+ byIp.set(s.ip, arr);
5698
+ }
5699
+ const ips = Array.from(byIp.entries()).map(([ip, snapshots]) => ({
5700
+ ip,
5701
+ latest: snapshots[0] ?? null,
5702
+ history: snapshots
5703
+ }));
5704
+ res.json({
5705
+ configured,
5706
+ intervalHours: cfg?.intervalHours ?? 24,
5707
+ ips
5708
+ });
5709
+ })
5710
+ );
5711
+ r.post(
5712
+ "/snds/refresh",
5713
+ asyncHandler(async (req, res) => {
5714
+ const result = await runSndsPull(mailer.getRunnerContext(), { force: true });
5715
+ await mailer.audit({
5716
+ actor: req.actor,
5717
+ action: "snds.refresh",
5718
+ resource: { collection: "mailer_snds_snapshots" },
5719
+ diffSummary: result.ran ? `parsed ${result.rowsParsed ?? 0}, persisted ${result.rowsPersisted ?? 0}` : `not run: ${result.reason}`
5720
+ });
5721
+ res.json(result);
5722
+ })
5723
+ );
3568
5724
  r.post(
3569
5725
  "/flows",
3570
5726
  asyncHandler(async (req, res) => {
@@ -3805,6 +5961,180 @@ function apiRouter(mailer) {
3805
5961
  return res.json({ ok: true });
3806
5962
  })
3807
5963
  );
5964
+ r.get(
5965
+ "/templates/:slug/mail-tester",
5966
+ asyncHandler(async (req, res) => {
5967
+ const cfg = mailer.config.mailTester;
5968
+ const configured = !!cfg?.apiKey;
5969
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
5970
+ if (!tpl) return res.status(404).json({ error: "not_found" });
5971
+ let bodyHash = "";
5972
+ let subject = tpl.subject ?? "";
5973
+ if (tpl.draft) {
5974
+ try {
5975
+ const compiled = tpl.draft.editorJson ? await compileMailyTemplate(tpl.draft.editorJson) : tpl.draft.mjml ? await compileTemplate(tpl.draft.mjml) : null;
5976
+ if (compiled) {
5977
+ bodyHash = sha256Hex(compiled.html);
5978
+ subject = tpl.draft.subject || subject;
5979
+ }
5980
+ } catch {
5981
+ }
5982
+ }
5983
+ if (!bodyHash && tpl.body?.html) {
5984
+ bodyHash = sha256Hex(tpl.body.html);
5985
+ }
5986
+ const key = bodyHash ? mailTesterContentKey({ bodyHash, subject, fromEmail: tpl.fromEmail }) : null;
5987
+ const cached = key ? await findCachedScore(mailer.getRunnerContext(), key) : null;
5988
+ res.json({
5989
+ configured,
5990
+ minScore: cfg?.minScore ?? 8,
5991
+ cacheHours: cfg?.cacheHours ?? 24,
5992
+ score: cached
5993
+ });
5994
+ })
5995
+ );
5996
+ r.post(
5997
+ "/templates/:slug/mail-tester-check",
5998
+ asyncHandler(async (req, res) => {
5999
+ const client = getMailTesterClient();
6000
+ if (!client) {
6001
+ return res.status(400).json({
6002
+ error: "not_configured",
6003
+ message: "Mail-Tester is not configured. Set mailer.config.mailTester.apiKey to enable."
6004
+ });
6005
+ }
6006
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6007
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6008
+ const draft = tpl.draft;
6009
+ if (!draft) return res.status(400).json({ error: "no_draft", message: "Save a draft before running a deliverability check." });
6010
+ let compiled;
6011
+ try {
6012
+ if (draft.editorJson) compiled = await compileMailyTemplate(draft.editorJson);
6013
+ else if (draft.mjml) compiled = await compileTemplate(draft.mjml);
6014
+ else return res.status(400).json({ error: "empty_draft" });
6015
+ } catch (err) {
6016
+ return res.status(400).json({ error: "compile_failed", message: String(err?.message ?? err) });
6017
+ }
6018
+ const bodyHash = sha256Hex(compiled.html);
6019
+ const contentKey = mailTesterContentKey({ bodyHash, subject: draft.subject, fromEmail: tpl.fromEmail });
6020
+ const ctx = mailer.getRunnerContext();
6021
+ const cached = await findCachedScore(ctx, contentKey);
6022
+ if (cached) {
6023
+ return res.json({ cached: true, status: "ready", score: cached });
6024
+ }
6025
+ const { checkId, emailAddress } = await client.provisionCheck();
6026
+ const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
6027
+ if (!provider) {
6028
+ return res.status(500).json({ error: "provider_unknown", message: `default provider ${mailer.config.defaultProvider} is not registered` });
6029
+ }
6030
+ try {
6031
+ await provider.send({
6032
+ to: emailAddress,
6033
+ fromName: tpl.fromName,
6034
+ fromEmail: tpl.fromEmail,
6035
+ replyTo: tpl.replyTo ?? void 0,
6036
+ subject: draft.subject,
6037
+ html: compiled.html,
6038
+ text: compiled.plainText,
6039
+ headers: {},
6040
+ messageMeta: { mailTesterCheckId: checkId }
6041
+ });
6042
+ } catch (err) {
6043
+ return res.status(502).json({ error: "send_failed", message: String(err?.message ?? err) });
6044
+ }
6045
+ await mailer.audit({
6046
+ actor: req.actor,
6047
+ action: "mail_tester.check.start",
6048
+ resource: { collection: "mailer_mail_tester_scores", id: contentKey, slug: tpl.slug },
6049
+ diffSummary: `Sent draft to ${emailAddress} for Mail-Tester check ${checkId}`
6050
+ });
6051
+ res.json({
6052
+ cached: false,
6053
+ status: "pending",
6054
+ checkId,
6055
+ contentKey,
6056
+ message: "Test email sent. Poll /mail-tester-result?checkId=...&contentKey=... for the score."
6057
+ });
6058
+ })
6059
+ );
6060
+ r.get(
6061
+ "/templates/:slug/mail-tester-result",
6062
+ asyncHandler(async (req, res) => {
6063
+ const client = getMailTesterClient();
6064
+ if (!client) return res.status(400).json({ error: "not_configured" });
6065
+ const checkId = String(req.query.checkId ?? "");
6066
+ if (!checkId) return res.status(400).json({ error: "validation_failed", message: "checkId is required" });
6067
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6068
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6069
+ const draft = tpl.draft;
6070
+ if (!draft) return res.status(400).json({ error: "no_draft", message: "Draft no longer exists." });
6071
+ let compiled;
6072
+ try {
6073
+ if (draft.editorJson) compiled = await compileMailyTemplate(draft.editorJson);
6074
+ else if (draft.mjml) compiled = await compileTemplate(draft.mjml);
6075
+ else return res.status(400).json({ error: "empty_draft" });
6076
+ } catch (err) {
6077
+ return res.status(400).json({ error: "compile_failed", message: String(err?.message ?? err) });
6078
+ }
6079
+ const contentKey = mailTesterContentKey({
6080
+ bodyHash: sha256Hex(compiled.html),
6081
+ subject: draft.subject,
6082
+ fromEmail: tpl.fromEmail
6083
+ });
6084
+ const result = await client.fetchResult(checkId);
6085
+ if (!result.ready) {
6086
+ return res.json({ status: "pending", score: null });
6087
+ }
6088
+ const ctx = mailer.getRunnerContext();
6089
+ const persisted = await persistScore(ctx, { templateSlug: tpl.slug, contentKey, checkId, result });
6090
+ await mailer.audit({
6091
+ actor: req.actor,
6092
+ action: "mail_tester.check.complete",
6093
+ resource: { collection: "mailer_mail_tester_scores", id: contentKey, slug: tpl.slug },
6094
+ diffSummary: `Score ${result.score.toFixed(1)}`
6095
+ });
6096
+ res.json({ status: "ready", score: persisted });
6097
+ })
6098
+ );
6099
+ r.post(
6100
+ "/templates/:slug/lint",
6101
+ asyncHandler(async (req, res) => {
6102
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
6103
+ if (!tpl) return res.status(404).json({ error: "not_found" });
6104
+ const body = req.body ?? {};
6105
+ const subject = body.subject ?? tpl.draft?.subject ?? tpl.subject ?? "";
6106
+ const preheader = body.preheader ?? tpl.draft?.preheader ?? tpl.preheader ?? "";
6107
+ const mjml = body.mjml ?? tpl.draft?.mjml ?? tpl.body?.mjml ?? "";
6108
+ const editorJson = body.editorJson !== void 0 ? body.editorJson : tpl.draft?.editorJson ?? tpl.body?.editorJson ?? null;
6109
+ const fromEmail = body.fromEmail ?? tpl.fromEmail;
6110
+ const kind = body.kind === "marketing" || body.kind === "transactional" ? body.kind : tpl.kind;
6111
+ let html = "";
6112
+ let plainText = "";
6113
+ try {
6114
+ if (editorJson) {
6115
+ const compiled = await compileMailyTemplate(editorJson);
6116
+ html = compiled.html;
6117
+ plainText = compiled.plainText;
6118
+ } else if (mjml) {
6119
+ const compiled = await compileTemplate(mjml);
6120
+ html = compiled.html;
6121
+ plainText = compiled.plainText;
6122
+ }
6123
+ } catch (err) {
6124
+ return res.status(200).json({
6125
+ errors: [{ rule: "compile_failed", severity: "error", message: `Compile error: ${String(err?.message ?? err)}` }],
6126
+ warnings: [],
6127
+ infos: [],
6128
+ compileFailed: true
6129
+ });
6130
+ }
6131
+ const lint = lintTemplate(
6132
+ { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
6133
+ { senderDomains: mailer.config.senderDomains }
6134
+ );
6135
+ res.json({ ...lint, compileFailed: false });
6136
+ })
6137
+ );
3808
6138
  r.post(
3809
6139
  "/templates/:slug/publish",
3810
6140
  asyncHandler(async (req, res) => {
@@ -3821,12 +6151,60 @@ function apiRouter(mailer) {
3821
6151
  });
3822
6152
  }
3823
6153
  let compiled;
3824
- if (draft.editorJson) {
3825
- compiled = await compileMailyTemplate(draft.editorJson);
3826
- } else if (draft.mjml) {
3827
- compiled = await compileTemplate(draft.mjml);
3828
- } else {
3829
- return res.status(400).json({ error: "empty_draft", message: "draft has no MJML or editorJson content" });
6154
+ try {
6155
+ if (draft.editorJson) {
6156
+ compiled = await compileMailyTemplate(draft.editorJson);
6157
+ } else if (draft.mjml) {
6158
+ compiled = await compileTemplate(draft.mjml);
6159
+ } else {
6160
+ return res.status(400).json({ error: "empty_draft", message: "draft has no MJML or editorJson content" });
6161
+ }
6162
+ } catch (err) {
6163
+ return res.status(422).json({
6164
+ error: "compile_failed",
6165
+ message: String(err?.message ?? err),
6166
+ lint: {
6167
+ errors: [{ rule: "compile_failed", severity: "error", message: `Compile error: ${String(err?.message ?? err)}` }],
6168
+ warnings: [],
6169
+ infos: []
6170
+ }
6171
+ });
6172
+ }
6173
+ const lint = lintTemplate(
6174
+ {
6175
+ subject: draft.subject,
6176
+ preheader: draft.preheader,
6177
+ mjml: draft.mjml ?? "",
6178
+ editorJson: draft.editorJson,
6179
+ html: compiled.html,
6180
+ plainText: compiled.plainText,
6181
+ kind: tpl.kind,
6182
+ fromEmail: tpl.fromEmail
6183
+ },
6184
+ { senderDomains: mailer.config.senderDomains }
6185
+ );
6186
+ if (lint.errors.length > 0) {
6187
+ return res.status(422).json({
6188
+ error: "lint_failed",
6189
+ message: `Template publish blocked by ${lint.errors.length} content issue(s).`,
6190
+ lint
6191
+ });
6192
+ }
6193
+ const bypass = Boolean(req.body?.bypassMailTester);
6194
+ if (!bypass) {
6195
+ const gate = await evaluateMailTesterGate(mailer.getRunnerContext(), {
6196
+ bodyHash: sha256Hex(compiled.html),
6197
+ subject: draft.subject,
6198
+ fromEmail: tpl.fromEmail
6199
+ });
6200
+ if (!gate.allowed) {
6201
+ return res.status(422).json({
6202
+ error: "mail_tester_blocked",
6203
+ message: gate.reason,
6204
+ score: gate.score,
6205
+ hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
6206
+ });
6207
+ }
3830
6208
  }
3831
6209
  const now = /* @__PURE__ */ new Date();
3832
6210
  const nextVersion = await c.templateVersions.countDocuments({ templateId: tpl._id }) + 1;
@@ -3867,7 +6245,13 @@ function apiRouter(mailer) {
3867
6245
  resource: { collection: "mailer_templates", id: tpl._id, slug: tpl.slug },
3868
6246
  diffSummary: `Published v${nextVersion}`
3869
6247
  });
3870
- return res.json({ ok: true, version: nextVersion, warnings: compiled.errors });
6248
+ return res.json({
6249
+ ok: true,
6250
+ version: nextVersion,
6251
+ warnings: compiled.errors,
6252
+ lint
6253
+ // warnings + infos for UI
6254
+ });
3871
6255
  })
3872
6256
  );
3873
6257
  r.post(
@@ -3883,6 +6267,9 @@ function apiRouter(mailer) {
3883
6267
  html = compiled.html;
3884
6268
  plainText = compiled.plainText;
3885
6269
  } else {
6270
+ if (!tpl.body?.html) {
6271
+ return res.status(409).json({ error: "not_published", message: "Template has not been published yet." });
6272
+ }
3886
6273
  html = tpl.body.html;
3887
6274
  plainText = tpl.body.plainText;
3888
6275
  }
@@ -4014,8 +6401,15 @@ function apiRouter(mailer) {
4014
6401
  if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
4015
6402
  if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
4016
6403
  }
4017
- const stageA = await mailer.adapter.count(hostFilter);
4018
- return res.json({ stageA, stageB: stageA, afterSuppression: stageA, computedMs: Date.now() - t0 });
6404
+ const hasMailerFilters = segmentDefinition.filters.some(
6405
+ (f) => ["subscriptionStatus", "firedEvent", "notFiredEvent", "notHasTag", "opened", "notOpened", "subscribedAfter", "subscribedBefore"].includes(f.kind)
6406
+ );
6407
+ const upperBound = await mailer.adapter.count(hostFilter);
6408
+ return res.json({
6409
+ upperBound,
6410
+ approximate: hasMailerFilters,
6411
+ computedMs: Date.now() - t0
6412
+ });
4019
6413
  })
4020
6414
  );
4021
6415
  r.post(
@@ -4095,6 +6489,119 @@ async function collectQueueCounts(mailer) {
4095
6489
  const [inFlight, delayed] = await Promise.all([sum("getInFlightCount"), sum("getDelayedCount")]);
4096
6490
  return { inFlight, delayed };
4097
6491
  }
6492
+ function emptyFlowStats() {
6493
+ return { activeRuns: 0, completedRuns: 0, sendsLast7Days: 0, sendsTotal: 0 };
6494
+ }
6495
+ async function computeFlowStats(mailer, slugFilter) {
6496
+ const out = /* @__PURE__ */ new Map();
6497
+ const match = slugFilter ? { flowSlug: slugFilter } : {};
6498
+ const runRows = await mailer.collections.flowRuns.aggregate([
6499
+ { $match: match },
6500
+ { $group: { _id: { flowSlug: "$flowSlug", status: "$status" }, count: { $sum: 1 } } }
6501
+ ]).toArray();
6502
+ for (const row of runRows) {
6503
+ const slug = row._id.flowSlug;
6504
+ if (!slug) continue;
6505
+ const cur = out.get(slug) ?? emptyFlowStats();
6506
+ if (row._id.status === "active") cur.activeRuns += row.count;
6507
+ else if (row._id.status === "completed") cur.completedRuns += row.count;
6508
+ out.set(slug, cur);
6509
+ }
6510
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
6511
+ const sendRows = await mailer.collections.sends.aggregate([
6512
+ { $match: { flowRunId: { $ne: null } } },
6513
+ {
6514
+ $lookup: {
6515
+ from: mailer.collections.flowRuns.collectionName,
6516
+ localField: "flowRunId",
6517
+ foreignField: "_id",
6518
+ as: "run",
6519
+ pipeline: slugFilter ? [{ $match: { flowSlug: slugFilter } }, { $project: { flowSlug: 1 } }] : [{ $project: { flowSlug: 1 } }]
6520
+ }
6521
+ },
6522
+ { $unwind: "$run" },
6523
+ {
6524
+ $group: {
6525
+ _id: "$run.flowSlug",
6526
+ total: { $sum: 1 },
6527
+ last7: { $sum: { $cond: [{ $gte: ["$queuedAt", sevenDaysAgo] }, 1, 0] } }
6528
+ }
6529
+ }
6530
+ ]).toArray();
6531
+ for (const row of sendRows) {
6532
+ if (!row._id) continue;
6533
+ const cur = out.get(row._id) ?? emptyFlowStats();
6534
+ cur.sendsTotal = row.total;
6535
+ cur.sendsLast7Days = row.last7;
6536
+ out.set(row._id, cur);
6537
+ }
6538
+ return out;
6539
+ }
6540
+ function emptyTemplateStats() {
6541
+ return { sent: 0, opened: 0, clicked: 0, bounced: 0, sentLast7Days: 0, lastSentAt: null };
6542
+ }
6543
+ async function computeTemplateStats(mailer, slugFilter) {
6544
+ const out = /* @__PURE__ */ new Map();
6545
+ const match = {};
6546
+ if (slugFilter) match.templateSlug = slugFilter;
6547
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
6548
+ const rows = await mailer.collections.sends.aggregate([
6549
+ { $match: match },
6550
+ {
6551
+ $group: {
6552
+ _id: "$templateSlug",
6553
+ sent: { $sum: 1 },
6554
+ opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
6555
+ clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
6556
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
6557
+ sentLast7Days: { $sum: { $cond: [{ $gte: ["$queuedAt", sevenDaysAgo] }, 1, 0] } },
6558
+ lastSentAt: { $max: "$queuedAt" }
6559
+ }
6560
+ }
6561
+ ]).toArray();
6562
+ for (const row of rows) {
6563
+ if (!row._id) continue;
6564
+ out.set(row._id, {
6565
+ sent: row.sent,
6566
+ opened: row.opened,
6567
+ clicked: row.clicked,
6568
+ bounced: row.bounced,
6569
+ sentLast7Days: row.sentLast7Days,
6570
+ lastSentAt: row.lastSentAt ?? null
6571
+ });
6572
+ }
6573
+ return out;
6574
+ }
6575
+ function emptyBroadcastStats() {
6576
+ return { delivered: 0, opened: 0, clicked: 0, bounced: 0 };
6577
+ }
6578
+ async function computeBroadcastStats(mailer, idFilter) {
6579
+ const out = /* @__PURE__ */ new Map();
6580
+ const match = { broadcastId: { $ne: null } };
6581
+ if (idFilter) match.broadcastId = idFilter;
6582
+ const rows = await mailer.collections.sends.aggregate([
6583
+ { $match: match },
6584
+ {
6585
+ $group: {
6586
+ _id: "$broadcastId",
6587
+ delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } },
6588
+ opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
6589
+ clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
6590
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } }
6591
+ }
6592
+ }
6593
+ ]).toArray();
6594
+ for (const row of rows) {
6595
+ if (!row._id) continue;
6596
+ out.set(String(row._id), {
6597
+ delivered: row.delivered,
6598
+ opened: row.opened,
6599
+ clicked: row.clicked,
6600
+ bounced: row.bounced
6601
+ });
6602
+ }
6603
+ return out;
6604
+ }
4098
6605
  var PIXEL = Buffer.from(
4099
6606
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
4100
6607
  "base64"