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