mailery 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/admin/spa/index-CjQTOX9H.js +41 -0
- package/dist/admin/spa/index-CjQTOX9H.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/template-editor-yH0Oz4Ix.js +502 -0
- package/dist/admin/spa/template-editor-yH0Oz4Ix.js.map +1 -0
- 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 +3040 -259
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -4
- package/dist/index.d.ts +62 -4
- package/dist/index.js +3034 -260
- package/dist/index.js.map +1 -1
- package/dist/{null-CGaNDDlQ.d.cts → null-B0rPgE5_.d.cts} +449 -4
- package/dist/{null-CGaNDDlQ.d.ts → null-B0rPgE5_.d.ts} +449 -4
- package/dist/testing.cjs +1199 -111
- 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 +1196 -111
- package/dist/testing.js.map +1 -1
- package/package.json +8 -1
- package/dist/admin/spa/index-CyOYk9BX.js +0 -41
- package/dist/admin/spa/index-CyOYk9BX.js.map +0 -1
- package/dist/admin/spa/template-editor-DjkGIZF_.js +0 -502
- package/dist/admin/spa/template-editor-DjkGIZF_.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var zod = require('zod');
|
|
3
4
|
var mongodb = require('mongodb');
|
|
4
5
|
var crypto2 = require('crypto');
|
|
5
6
|
var sgMail = require('@sendgrid/mail');
|
|
6
|
-
var zod = require('zod');
|
|
7
7
|
var IORedis = require('ioredis');
|
|
8
8
|
var Handlebars = require('handlebars');
|
|
9
9
|
var htmlToText = require('html-to-text');
|
|
10
10
|
var mjml2html = require('mjml');
|
|
11
|
+
var dns = require('dns/promises');
|
|
12
|
+
var net = require('net');
|
|
13
|
+
var psl = require('psl');
|
|
11
14
|
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
|
};
|
|
@@ -35,6 +49,55 @@ var __export = (target, all) => {
|
|
|
35
49
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
36
50
|
};
|
|
37
51
|
|
|
52
|
+
// src/server/adapters/vars.ts
|
|
53
|
+
var vars_exports = {};
|
|
54
|
+
__export(vars_exports, {
|
|
55
|
+
RESERVED_VAR_KEYS: () => exports.RESERVED_VAR_KEYS,
|
|
56
|
+
assertNoReservedVarKeys: () => assertNoReservedVarKeys,
|
|
57
|
+
defineVars: () => defineVars,
|
|
58
|
+
resolveVars: () => resolveVars,
|
|
59
|
+
varsJsonSchema: () => varsJsonSchema
|
|
60
|
+
});
|
|
61
|
+
function defineVars(adapter) {
|
|
62
|
+
return adapter;
|
|
63
|
+
}
|
|
64
|
+
function assertNoReservedVarKeys(adapter) {
|
|
65
|
+
const json = varsJsonSchema(adapter);
|
|
66
|
+
const props = json && typeof json === "object" ? json.properties : void 0;
|
|
67
|
+
if (!props) return;
|
|
68
|
+
const clashes = exports.RESERVED_VAR_KEYS.filter((k) => k in props);
|
|
69
|
+
if (clashes.length > 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function varsJsonSchema(adapter) {
|
|
76
|
+
return zod.z.toJSONSchema(adapter.schema, { io: "output" });
|
|
77
|
+
}
|
|
78
|
+
async function resolveVars(adapter, contact, info) {
|
|
79
|
+
if (!adapter) return {};
|
|
80
|
+
const resolved = await adapter.resolve(contact, info);
|
|
81
|
+
if (!resolved || typeof resolved !== "object") return {};
|
|
82
|
+
const out = { ...resolved };
|
|
83
|
+
for (const k of exports.RESERVED_VAR_KEYS) delete out[k];
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
exports.RESERVED_VAR_KEYS = void 0;
|
|
87
|
+
var init_vars = __esm({
|
|
88
|
+
"src/server/adapters/vars.ts"() {
|
|
89
|
+
exports.RESERVED_VAR_KEYS = [
|
|
90
|
+
"contact",
|
|
91
|
+
"vars",
|
|
92
|
+
"event",
|
|
93
|
+
"unsubscribeUrl",
|
|
94
|
+
"viewInBrowserUrl",
|
|
95
|
+
"preferenceCenterUrl",
|
|
96
|
+
"senderAddress"
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
38
101
|
// src/server/adapters/mongo.ts
|
|
39
102
|
var mongo_exports = {};
|
|
40
103
|
__export(mongo_exports, {
|
|
@@ -348,6 +411,12 @@ var tagInputSchema = zod.z.object({
|
|
|
348
411
|
externalId: externalIdSchema,
|
|
349
412
|
tag: zod.z.string().min(1).max(128)
|
|
350
413
|
});
|
|
414
|
+
var abortFlowInputSchema = zod.z.object({
|
|
415
|
+
flowSlug: slugSchema,
|
|
416
|
+
externalId: externalIdSchema,
|
|
417
|
+
reason: zod.z.string().min(1).max(200).optional()
|
|
418
|
+
});
|
|
419
|
+
var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
|
|
351
420
|
var sendOneOffInputSchema = zod.z.object({
|
|
352
421
|
templateSlug: slugSchema,
|
|
353
422
|
externalId: externalIdSchema,
|
|
@@ -377,7 +446,13 @@ var flowStepSchema = zod.z.lazy(
|
|
|
377
446
|
type: zod.z.literal("send"),
|
|
378
447
|
templateSlug: slugSchema,
|
|
379
448
|
providerOverride: zod.z.string().optional(),
|
|
380
|
-
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
449
|
+
vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
|
|
450
|
+
delivery: zod.z.object({
|
|
451
|
+
weekdaysOnly: zod.z.boolean().optional(),
|
|
452
|
+
timeOfDay: zod.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
|
|
453
|
+
useContactTimezone: zod.z.boolean().optional(),
|
|
454
|
+
timezone: zod.z.string().optional()
|
|
455
|
+
}).optional()
|
|
381
456
|
}),
|
|
382
457
|
zod.z.object({
|
|
383
458
|
type: zod.z.literal("tag"),
|
|
@@ -461,6 +536,17 @@ var predicateSchema = zod.z.lazy(
|
|
|
461
536
|
);
|
|
462
537
|
|
|
463
538
|
// src/server/config.ts
|
|
539
|
+
var DEFAULT_DOMAIN_DNSBL_LISTS = [
|
|
540
|
+
{ host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
|
|
541
|
+
{ host: "multi.surbl.org", label: "SURBL" },
|
|
542
|
+
{ host: "multi.uribl.com", label: "URIBL" }
|
|
543
|
+
];
|
|
544
|
+
var DEFAULT_IP_DNSBL_LISTS = [
|
|
545
|
+
{ host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
|
|
546
|
+
{ host: "b.barracudacentral.org", label: "Barracuda" },
|
|
547
|
+
{ host: "dnsbl.sorbs.net", label: "SORBS" },
|
|
548
|
+
{ host: "bl.spamcop.net", label: "SpamCop" }
|
|
549
|
+
];
|
|
464
550
|
var DEFAULTS = {
|
|
465
551
|
collectionPrefix: "mailer_",
|
|
466
552
|
requireDoubleOptIn: false,
|
|
@@ -521,6 +607,10 @@ function resolveConfig(c) {
|
|
|
521
607
|
}
|
|
522
608
|
|
|
523
609
|
// src/server/models/index.ts
|
|
610
|
+
var HEALTH_AGG_ID = "agg";
|
|
611
|
+
function healthBucketId(senderDomain, kind) {
|
|
612
|
+
return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
|
|
613
|
+
}
|
|
524
614
|
function getCollections(db, prefix = "mailer_") {
|
|
525
615
|
return {
|
|
526
616
|
subscriptions: db.collection(`${prefix}subscriptions`),
|
|
@@ -538,7 +628,14 @@ function getCollections(db, prefix = "mailer_") {
|
|
|
538
628
|
auditLog: db.collection(`${prefix}audit_log`),
|
|
539
629
|
webhookEvents: db.collection(`${prefix}webhook_events`),
|
|
540
630
|
health: db.collection(`${prefix}health`),
|
|
541
|
-
contactTags: db.collection(`${prefix}contact_tags`)
|
|
631
|
+
contactTags: db.collection(`${prefix}contact_tags`),
|
|
632
|
+
dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
|
|
633
|
+
postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
|
|
634
|
+
sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
|
|
635
|
+
dmarcReports: db.collection(`${prefix}dmarc_reports`),
|
|
636
|
+
dmarcFailures: db.collection(`${prefix}dmarc_failures`),
|
|
637
|
+
dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
|
|
638
|
+
mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
|
|
542
639
|
};
|
|
543
640
|
}
|
|
544
641
|
async function ensureIndexes(db, prefix = "mailer_") {
|
|
@@ -610,8 +707,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
610
707
|
c.contactTags.createIndexes([
|
|
611
708
|
{ key: { externalId: 1, tag: 1 }, unique: true },
|
|
612
709
|
{ key: { tag: 1 } }
|
|
710
|
+
]),
|
|
711
|
+
c.health.createIndexes([
|
|
712
|
+
{ key: { senderDomain: 1, kind: 1 } },
|
|
713
|
+
{ key: { status: 1 } },
|
|
714
|
+
// setup-status reads the most-recently-touched health doc as a heartbeat.
|
|
715
|
+
{ key: { updatedAt: -1 } }
|
|
716
|
+
]),
|
|
717
|
+
c.dnsblChecks.createIndexes([
|
|
718
|
+
{ key: { target: 1, list: 1 }, unique: true },
|
|
719
|
+
// Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
|
|
720
|
+
{ key: { result: 1, target: 1, list: 1 } },
|
|
721
|
+
// TTL — stale rows for targets the operator removed disappear after
|
|
722
|
+
// 60 days without manual cleanup. The puller refreshes runAt on
|
|
723
|
+
// every active target so existing targets stay indefinitely.
|
|
724
|
+
{ key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
|
|
725
|
+
]),
|
|
726
|
+
c.postmasterSnapshots.createIndexes([
|
|
727
|
+
{ key: { domain: 1, date: 1 }, unique: true },
|
|
728
|
+
{ key: { domain: 1, fetchedAt: -1 } },
|
|
729
|
+
{ key: { domainReputation: 1 } }
|
|
730
|
+
]),
|
|
731
|
+
c.sndsSnapshots.createIndexes([
|
|
732
|
+
{ key: { ip: 1, activityStart: 1 }, unique: true },
|
|
733
|
+
{ key: { ip: 1, fetchedAt: -1 } },
|
|
734
|
+
{ key: { filterResult: 1 } }
|
|
735
|
+
]),
|
|
736
|
+
c.dmarcReports.createIndexes([
|
|
737
|
+
{ key: { reportId: 1, orgName: 1 }, unique: true },
|
|
738
|
+
{ key: { domain: 1, rangeEnd: -1 } },
|
|
739
|
+
// Cross-domain "most recent reports" queries scan a lot without this.
|
|
740
|
+
{ key: { rangeEnd: -1 } },
|
|
741
|
+
{ key: { receivedAt: -1 } }
|
|
742
|
+
]),
|
|
743
|
+
c.dmarcFailures.createIndexes([
|
|
744
|
+
{ key: { reportId: 1, sourceIp: 1 }, unique: true },
|
|
745
|
+
{ key: { domain: 1, day: -1 } },
|
|
746
|
+
{ key: { sourceIp: 1, day: -1 } },
|
|
747
|
+
{ key: { receivedAt: 1 } }
|
|
748
|
+
// for retention pruning
|
|
749
|
+
]),
|
|
750
|
+
c.dmarcSourceTags.createIndexes([
|
|
751
|
+
{ key: { ip: 1 }, unique: true }
|
|
752
|
+
]),
|
|
753
|
+
c.mailTesterScores.createIndexes([
|
|
754
|
+
{ key: { contentKey: 1 }, unique: true },
|
|
755
|
+
{ key: { templateSlug: 1, fetchedAt: -1 } },
|
|
756
|
+
// TTL — Mongo auto-deletes expired scores so we never serve stale data.
|
|
757
|
+
{ key: { expiresAt: 1 }, expireAfterSeconds: 0 }
|
|
613
758
|
])
|
|
614
759
|
]);
|
|
760
|
+
await c.health.deleteOne({ _id: "singleton" }).catch(() => {
|
|
761
|
+
});
|
|
615
762
|
}
|
|
616
763
|
var EventRegistry = class {
|
|
617
764
|
policies = /* @__PURE__ */ new Map();
|
|
@@ -624,6 +771,9 @@ var EventRegistry = class {
|
|
|
624
771
|
policy(name) {
|
|
625
772
|
return this.policies.get(name);
|
|
626
773
|
}
|
|
774
|
+
list() {
|
|
775
|
+
return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
|
|
776
|
+
}
|
|
627
777
|
/**
|
|
628
778
|
* Derive a dedupeKey for an event call. Returns null when no policy is
|
|
629
779
|
* registered AND no key was passed — caller should throw.
|
|
@@ -1092,6 +1242,7 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1092
1242
|
flowSlug: flow.slug,
|
|
1093
1243
|
flowVersion: flow.version,
|
|
1094
1244
|
emailAtEntry: sub.emailAtSubscribe,
|
|
1245
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1095
1246
|
enteredAt: /* @__PURE__ */ new Date(),
|
|
1096
1247
|
status: "active",
|
|
1097
1248
|
currentStepIndex: 0,
|
|
@@ -1189,6 +1340,105 @@ function effectiveLowerBound(ctx, opts) {
|
|
|
1189
1340
|
}
|
|
1190
1341
|
return null;
|
|
1191
1342
|
}
|
|
1343
|
+
|
|
1344
|
+
// src/server/runner/delivery-window.ts
|
|
1345
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1346
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1347
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1348
|
+
let candidate = now;
|
|
1349
|
+
if (window.timeOfDay) {
|
|
1350
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1351
|
+
const local = localParts(candidate, tz);
|
|
1352
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1353
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1354
|
+
candidate = todaySlot;
|
|
1355
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1356
|
+
const next = addLocalDays(local, 1);
|
|
1357
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (window.weekdaysOnly) {
|
|
1361
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1362
|
+
const local = localParts(candidate, tz);
|
|
1363
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1364
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1365
|
+
const moved = addLocalDays(local, shift);
|
|
1366
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return candidate;
|
|
1370
|
+
}
|
|
1371
|
+
function pickTimezone(window, contactTimezone) {
|
|
1372
|
+
const candidates = [
|
|
1373
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1374
|
+
window.timezone,
|
|
1375
|
+
"UTC"
|
|
1376
|
+
];
|
|
1377
|
+
for (const tz of candidates) {
|
|
1378
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1379
|
+
}
|
|
1380
|
+
return "UTC";
|
|
1381
|
+
}
|
|
1382
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1383
|
+
function isValidTimezone(tz) {
|
|
1384
|
+
const cached = validatedZones.get(tz);
|
|
1385
|
+
if (cached !== void 0) return cached;
|
|
1386
|
+
let ok = true;
|
|
1387
|
+
try {
|
|
1388
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1389
|
+
} catch {
|
|
1390
|
+
ok = false;
|
|
1391
|
+
}
|
|
1392
|
+
validatedZones.set(tz, ok);
|
|
1393
|
+
return ok;
|
|
1394
|
+
}
|
|
1395
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1396
|
+
function formatterFor(tz) {
|
|
1397
|
+
let f = partFormatters.get(tz);
|
|
1398
|
+
if (!f) {
|
|
1399
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1400
|
+
timeZone: tz,
|
|
1401
|
+
year: "numeric",
|
|
1402
|
+
month: "2-digit",
|
|
1403
|
+
day: "2-digit",
|
|
1404
|
+
hour: "2-digit",
|
|
1405
|
+
minute: "2-digit",
|
|
1406
|
+
second: "2-digit",
|
|
1407
|
+
weekday: "short",
|
|
1408
|
+
hour12: false
|
|
1409
|
+
});
|
|
1410
|
+
partFormatters.set(tz, f);
|
|
1411
|
+
}
|
|
1412
|
+
return f;
|
|
1413
|
+
}
|
|
1414
|
+
function localParts(date, tz) {
|
|
1415
|
+
const parts = {};
|
|
1416
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1417
|
+
return {
|
|
1418
|
+
y: Number(parts.year),
|
|
1419
|
+
mo: Number(parts.month),
|
|
1420
|
+
d: Number(parts.day),
|
|
1421
|
+
hh: Number(parts.hour) % 24,
|
|
1422
|
+
// Intl emits '24' for midnight in some locales
|
|
1423
|
+
mi: Number(parts.minute),
|
|
1424
|
+
ss: Number(parts.second),
|
|
1425
|
+
weekday: parts.weekday
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
function utcFromLocal(y, mo, d, hh, mi, tz) {
|
|
1429
|
+
let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
|
1430
|
+
for (let i = 0; i < 2; i++) {
|
|
1431
|
+
const p = localParts(new Date(ts), tz);
|
|
1432
|
+
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
|
1433
|
+
const offset = asUtc - ts;
|
|
1434
|
+
ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
|
|
1435
|
+
}
|
|
1436
|
+
return new Date(ts);
|
|
1437
|
+
}
|
|
1438
|
+
function addLocalDays(p, days) {
|
|
1439
|
+
const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
|
|
1440
|
+
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
1441
|
+
}
|
|
1192
1442
|
async function compileTemplate(mjml) {
|
|
1193
1443
|
const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
|
|
1194
1444
|
const plainText = derivePlaintext(out.html);
|
|
@@ -1312,6 +1562,9 @@ function makeHandlebars(extra) {
|
|
|
1312
1562
|
return hb;
|
|
1313
1563
|
}
|
|
1314
1564
|
|
|
1565
|
+
// src/server/runner/send.ts
|
|
1566
|
+
init_vars();
|
|
1567
|
+
|
|
1315
1568
|
// src/server/runner/suppression.ts
|
|
1316
1569
|
var SCOPES_BY_KIND = {
|
|
1317
1570
|
marketing: ["all", "marketing"],
|
|
@@ -1334,21 +1587,88 @@ async function isSuppressed(collections, email, kind) {
|
|
|
1334
1587
|
return { suppressed: false };
|
|
1335
1588
|
}
|
|
1336
1589
|
|
|
1590
|
+
// src/server/templates/sender-domain.ts
|
|
1591
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1592
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1593
|
+
const domain = extractDomain(fromEmail);
|
|
1594
|
+
if (!domain) {
|
|
1595
|
+
return {
|
|
1596
|
+
ok: false,
|
|
1597
|
+
code: "invalid_email",
|
|
1598
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
const entry = registry[domain];
|
|
1602
|
+
if (!entry) {
|
|
1603
|
+
const known = Object.keys(registry).join(", ");
|
|
1604
|
+
return {
|
|
1605
|
+
ok: false,
|
|
1606
|
+
code: "unregistered_domain",
|
|
1607
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
if (entry.kind === "both") return { ok: true };
|
|
1611
|
+
if (entry.kind !== templateKind) {
|
|
1612
|
+
return {
|
|
1613
|
+
ok: false,
|
|
1614
|
+
code: "wrong_kind",
|
|
1615
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
return { ok: true };
|
|
1619
|
+
}
|
|
1620
|
+
function extractDomain(email) {
|
|
1621
|
+
if (typeof email !== "string") return null;
|
|
1622
|
+
const at = email.lastIndexOf("@");
|
|
1623
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
1624
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1337
1627
|
// src/server/runner/health.ts
|
|
1338
|
-
|
|
1628
|
+
var ZERO_COUNTERS = {
|
|
1629
|
+
sent: 0,
|
|
1630
|
+
delivered: 0,
|
|
1631
|
+
bounced: 0,
|
|
1632
|
+
hardBounced: 0,
|
|
1633
|
+
softBounced: 0,
|
|
1634
|
+
complained: 0,
|
|
1635
|
+
failedToSend: 0
|
|
1636
|
+
};
|
|
1637
|
+
var ZERO_RATES = {
|
|
1638
|
+
bounceRate: 0,
|
|
1639
|
+
hardBounceRate: 0,
|
|
1640
|
+
complaintRate: 0,
|
|
1641
|
+
failureRate: 0
|
|
1642
|
+
};
|
|
1643
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1644
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1645
|
+
const writes = [];
|
|
1646
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1647
|
+
if (dims) {
|
|
1648
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1649
|
+
if (domain) {
|
|
1650
|
+
const id = healthBucketId(domain, dims.kind);
|
|
1651
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
await Promise.all(writes);
|
|
1655
|
+
}
|
|
1656
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1339
1657
|
await ctx.collections.health.updateOne(
|
|
1340
|
-
{ _id
|
|
1658
|
+
{ _id },
|
|
1341
1659
|
{
|
|
1342
1660
|
$inc: { [`counters.${counter2}`]: by },
|
|
1343
1661
|
$setOnInsert: {
|
|
1344
|
-
_id
|
|
1662
|
+
_id,
|
|
1663
|
+
senderDomain,
|
|
1664
|
+
kind,
|
|
1345
1665
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1346
|
-
windowDurationMs:
|
|
1666
|
+
windowDurationMs: windowMs,
|
|
1347
1667
|
status: "healthy",
|
|
1348
1668
|
trippedAt: null,
|
|
1349
1669
|
trippedReason: null,
|
|
1350
1670
|
manuallyResumedAt: null,
|
|
1351
|
-
rates: {
|
|
1671
|
+
rates: { ...ZERO_RATES }
|
|
1352
1672
|
},
|
|
1353
1673
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1354
1674
|
},
|
|
@@ -1358,73 +1678,116 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
|
|
|
1358
1678
|
async function evaluateHealth(ctx) {
|
|
1359
1679
|
const cb = ctx.config.circuitBreaker;
|
|
1360
1680
|
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
|
-
|
|
1681
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
1682
|
+
if (docs.length === 0) return;
|
|
1683
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1684
|
+
if (!hasAgg) {
|
|
1685
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1686
|
+
}
|
|
1687
|
+
for (const doc of docs) {
|
|
1688
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1689
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1690
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1691
|
+
await ctx.collections.health.updateOne(
|
|
1692
|
+
{ _id: doc._id },
|
|
1693
|
+
{
|
|
1694
|
+
$set: {
|
|
1695
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1696
|
+
windowDurationMs: windowMs,
|
|
1697
|
+
counters: { ...ZERO_COUNTERS },
|
|
1698
|
+
rates: { ...ZERO_RATES },
|
|
1699
|
+
status: "healthy",
|
|
1700
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1701
|
+
}
|
|
1374
1702
|
}
|
|
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) {
|
|
1703
|
+
);
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
const c = doc.counters;
|
|
1707
|
+
const total = c.sent || 1;
|
|
1708
|
+
const rates = {
|
|
1709
|
+
bounceRate: c.bounced / total,
|
|
1710
|
+
hardBounceRate: c.hardBounced / total,
|
|
1711
|
+
complaintRate: c.complained / total,
|
|
1712
|
+
failureRate: c.failedToSend / total
|
|
1713
|
+
};
|
|
1402
1714
|
await ctx.collections.health.updateOne(
|
|
1403
|
-
{ _id:
|
|
1404
|
-
{ $set: {
|
|
1715
|
+
{ _id: doc._id },
|
|
1716
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1405
1717
|
);
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1718
|
+
if (isAgg) continue;
|
|
1719
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1720
|
+
if (doc.status === "tripped") continue;
|
|
1721
|
+
let trippedReason = null;
|
|
1722
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1723
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1724
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1725
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1726
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1727
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1728
|
+
}
|
|
1729
|
+
if (trippedReason) {
|
|
1730
|
+
const result = await ctx.collections.health.updateOne(
|
|
1731
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1732
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1733
|
+
);
|
|
1734
|
+
if (result.modifiedCount > 0) {
|
|
1735
|
+
if (ctx.audit) {
|
|
1736
|
+
try {
|
|
1737
|
+
await ctx.audit({
|
|
1738
|
+
actor: "system:circuit-breaker",
|
|
1739
|
+
action: "health.trip",
|
|
1740
|
+
resource: {
|
|
1741
|
+
collection: "mailer_health",
|
|
1742
|
+
id: String(doc._id),
|
|
1743
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1744
|
+
},
|
|
1745
|
+
diffSummary: trippedReason
|
|
1746
|
+
});
|
|
1747
|
+
} catch {
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
1751
|
+
try {
|
|
1752
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
1753
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1754
|
+
rates
|
|
1755
|
+
});
|
|
1756
|
+
} catch {
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1410
1759
|
}
|
|
1760
|
+
continue;
|
|
1411
1761
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1762
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1763
|
+
if (doc.status !== "degraded") {
|
|
1764
|
+
await ctx.collections.health.updateOne(
|
|
1765
|
+
{ _id: doc._id },
|
|
1766
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
} else if (doc.status === "degraded") {
|
|
1416
1770
|
await ctx.collections.health.updateOne(
|
|
1417
|
-
{ _id:
|
|
1418
|
-
{ $set: { status: "
|
|
1771
|
+
{ _id: doc._id },
|
|
1772
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1419
1773
|
);
|
|
1420
1774
|
}
|
|
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
1775
|
}
|
|
1427
1776
|
}
|
|
1777
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
1778
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
1779
|
+
const id = healthBucketId(domain, kind);
|
|
1780
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
1781
|
+
}
|
|
1782
|
+
function effectiveOverallStatus(docs) {
|
|
1783
|
+
const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
|
|
1784
|
+
if (buckets.length === 0) {
|
|
1785
|
+
return docs.length === 0 ? null : "healthy";
|
|
1786
|
+
}
|
|
1787
|
+
if (buckets.some((d) => d.status === "tripped")) return "tripped";
|
|
1788
|
+
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1789
|
+
return "healthy";
|
|
1790
|
+
}
|
|
1428
1791
|
|
|
1429
1792
|
// src/server/runner/send.ts
|
|
1430
1793
|
async function handleSend(run, step, contact, flow, ctx) {
|
|
@@ -1508,8 +1871,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1508
1871
|
return;
|
|
1509
1872
|
}
|
|
1510
1873
|
if (send.kind === "marketing") {
|
|
1511
|
-
const
|
|
1512
|
-
if (
|
|
1874
|
+
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
1875
|
+
if (bucket?.status === "tripped") {
|
|
1513
1876
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
1514
1877
|
return;
|
|
1515
1878
|
}
|
|
@@ -1520,13 +1883,29 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1520
1883
|
return;
|
|
1521
1884
|
}
|
|
1522
1885
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1886
|
+
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
1887
|
+
await ctx.collections.sends.updateOne(
|
|
1888
|
+
{ _id: send._id },
|
|
1889
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1890
|
+
);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
let renderCtx;
|
|
1894
|
+
let rendered;
|
|
1895
|
+
try {
|
|
1896
|
+
const resolved = await resolveVars(ctx.varsAdapter, contact, {
|
|
1897
|
+
reason: "send",
|
|
1898
|
+
templateSlug: template.slug,
|
|
1899
|
+
flowSlug: run?.flowSlug,
|
|
1900
|
+
eventName: run?.triggerEvent?.name,
|
|
1901
|
+
eventProperties: run?.triggerEvent?.properties
|
|
1902
|
+
});
|
|
1903
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
1904
|
+
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
1907
|
+
throw err;
|
|
1908
|
+
}
|
|
1530
1909
|
const tracking = applyTracking(rendered.html, {
|
|
1531
1910
|
sendId: String(send._id),
|
|
1532
1911
|
publicUrl: ctx.config.publicUrl,
|
|
@@ -1578,13 +1957,13 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1578
1957
|
}
|
|
1579
1958
|
}
|
|
1580
1959
|
);
|
|
1581
|
-
await recordHealthCounter(ctx, "sent");
|
|
1960
|
+
await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
|
|
1582
1961
|
} catch (err) {
|
|
1583
1962
|
await ctx.collections.sends.updateOne(
|
|
1584
1963
|
{ _id: send._id },
|
|
1585
1964
|
{ $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
|
|
1586
1965
|
);
|
|
1587
|
-
await recordHealthCounter(ctx, "failedToSend");
|
|
1966
|
+
await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
|
|
1588
1967
|
if (ctx.config.onSendFailure) {
|
|
1589
1968
|
try {
|
|
1590
1969
|
await ctx.config.onSendFailure({ send, error: err });
|
|
@@ -1602,7 +1981,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
1602
1981
|
}
|
|
1603
1982
|
return ctx.config.defaultProvider;
|
|
1604
1983
|
}
|
|
1605
|
-
function buildRenderContext(contact, run, vars, ctx) {
|
|
1984
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
1606
1985
|
const scope = "marketing";
|
|
1607
1986
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
1608
1987
|
const token = signUnsubscribeToken(
|
|
@@ -1611,8 +1990,10 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
1611
1990
|
);
|
|
1612
1991
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
1613
1992
|
return {
|
|
1993
|
+
...resolved,
|
|
1614
1994
|
contact,
|
|
1615
1995
|
vars,
|
|
1996
|
+
event: run?.triggerEvent?.properties ?? {},
|
|
1616
1997
|
unsubscribeUrl,
|
|
1617
1998
|
senderAddress: ctx.config.senderAddress
|
|
1618
1999
|
};
|
|
@@ -1697,8 +2078,15 @@ async function processOneRunStep(runId, ctx) {
|
|
|
1697
2078
|
return handleCondition(run, step, contact, ctx);
|
|
1698
2079
|
case "branch":
|
|
1699
2080
|
return handleBranch(run, step, contact, ctx);
|
|
1700
|
-
case "send":
|
|
2081
|
+
case "send": {
|
|
2082
|
+
if (step.delivery) {
|
|
2083
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
|
|
2084
|
+
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
2085
|
+
return deferSendForWindow(run, deliverAt, ctx);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
1701
2088
|
return handleSend(run, step, contact, flow, ctx);
|
|
2089
|
+
}
|
|
1702
2090
|
case "tag":
|
|
1703
2091
|
return handleTag(run, step, ctx);
|
|
1704
2092
|
case "fire_event":
|
|
@@ -1849,6 +2237,34 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
1849
2237
|
}
|
|
1850
2238
|
}
|
|
1851
2239
|
}
|
|
2240
|
+
async function deferSendForWindow(run, deliverAt, ctx) {
|
|
2241
|
+
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2242
|
+
// Only write once per deferral — if nextActionAt already points at (or
|
|
2243
|
+
// past) the slot, another worker/tick got here first.
|
|
2244
|
+
{ _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
|
|
2245
|
+
{
|
|
2246
|
+
$set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
|
|
2247
|
+
$push: {
|
|
2248
|
+
history: {
|
|
2249
|
+
stepIndex: run.currentStepIndex,
|
|
2250
|
+
action: "send_deferred",
|
|
2251
|
+
at: /* @__PURE__ */ new Date(),
|
|
2252
|
+
details: { until: deliverAt }
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
},
|
|
2256
|
+
{ returnDocument: "after" }
|
|
2257
|
+
);
|
|
2258
|
+
if (!updated) return;
|
|
2259
|
+
await ctx.queues.advance.add(
|
|
2260
|
+
"advance",
|
|
2261
|
+
{ flowRunId: String(run._id) },
|
|
2262
|
+
{
|
|
2263
|
+
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2264
|
+
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2265
|
+
}
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
1852
2268
|
async function advanceStep(run, ctx, log, opts = {}) {
|
|
1853
2269
|
const stepInc = opts.stepInc ?? 1;
|
|
1854
2270
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
@@ -2168,7 +2584,15 @@ async function promoteSoftBounces(ctx) {
|
|
|
2168
2584
|
const cutoff = new Date(Date.now() - windowDays * 864e5);
|
|
2169
2585
|
const offenders = await ctx.collections.sends.aggregate([
|
|
2170
2586
|
{ $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
|
|
2171
|
-
{ $
|
|
2587
|
+
{ $sort: { queuedAt: 1 } },
|
|
2588
|
+
{
|
|
2589
|
+
$group: {
|
|
2590
|
+
_id: "$emailAtSend",
|
|
2591
|
+
count: { $sum: 1 },
|
|
2592
|
+
lastFromEmail: { $last: "$fromEmail" },
|
|
2593
|
+
lastKind: { $last: "$kind" }
|
|
2594
|
+
}
|
|
2595
|
+
},
|
|
2172
2596
|
{ $match: { count: { $gte: threshold } } },
|
|
2173
2597
|
{ $limit: 200 }
|
|
2174
2598
|
]).toArray();
|
|
@@ -2200,81 +2624,984 @@ async function promoteSoftBounces(ctx) {
|
|
|
2200
2624
|
{ emailAtSubscribe: email },
|
|
2201
2625
|
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2202
2626
|
);
|
|
2203
|
-
await recordHealthCounter(
|
|
2627
|
+
await recordHealthCounter(
|
|
2628
|
+
ctx,
|
|
2629
|
+
"hardBounced",
|
|
2630
|
+
o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
|
|
2631
|
+
);
|
|
2204
2632
|
}
|
|
2205
2633
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
var
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2634
|
+
var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
|
|
2635
|
+
var DNS_CONCURRENCY = 8;
|
|
2636
|
+
var defaultResolver = {
|
|
2637
|
+
resolve4: (hostname) => dns__default.default.resolve4(hostname)
|
|
2638
|
+
};
|
|
2639
|
+
async function runDnsblChecks(ctx, opts = {}) {
|
|
2640
|
+
const cfg = ctx.config.dnsbl ?? {};
|
|
2641
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
2642
|
+
if (!opts.force && intervalHours <= 0) {
|
|
2643
|
+
return { ran: false, reason: "disabled" };
|
|
2644
|
+
}
|
|
2645
|
+
const targets = collectTargets(ctx, cfg);
|
|
2646
|
+
if (targets.domains.length === 0 && targets.ips.length === 0) {
|
|
2647
|
+
return { ran: false, reason: "no_targets" };
|
|
2648
|
+
}
|
|
2649
|
+
const resolver = opts.resolver ?? defaultResolver;
|
|
2650
|
+
const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
|
|
2651
|
+
const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
|
|
2652
|
+
const pairs = [];
|
|
2653
|
+
for (const d of targets.domains) {
|
|
2654
|
+
for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
|
|
2655
|
+
}
|
|
2656
|
+
for (const ip of targets.ips) {
|
|
2657
|
+
for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
|
|
2658
|
+
}
|
|
2659
|
+
const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
|
|
2660
|
+
let duePairs = pairs;
|
|
2661
|
+
if (throttleCutoff != null) {
|
|
2662
|
+
const existing = await ctx.collections.dnsblChecks.find(
|
|
2663
|
+
{
|
|
2664
|
+
$or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
|
|
2665
|
+
},
|
|
2666
|
+
{ projection: { target: 1, list: 1, runAt: 1 } }
|
|
2667
|
+
).toArray();
|
|
2668
|
+
const fresh = /* @__PURE__ */ new Set();
|
|
2669
|
+
for (const e of existing) {
|
|
2670
|
+
if (new Date(e.runAt).getTime() > throttleCutoff) {
|
|
2671
|
+
fresh.add(`${e.target}|${e.list}`);
|
|
2224
2672
|
}
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
|
|
2253
|
-
const cursor = ctx.collections.sends.find(
|
|
2254
|
-
{ status: "sending", updatedAt: { $lt: cutoff } },
|
|
2255
|
-
{ projection: { _id: 1 } }
|
|
2256
|
-
).limit(500);
|
|
2257
|
-
for await (const row of cursor) {
|
|
2258
|
-
const reset = await ctx.collections.sends.updateOne(
|
|
2259
|
-
{ _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
2260
|
-
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2673
|
+
}
|
|
2674
|
+
duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
|
|
2675
|
+
if (duePairs.length === 0) {
|
|
2676
|
+
return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
let listedCount = 0;
|
|
2680
|
+
async function processPair(p) {
|
|
2681
|
+
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
2682
|
+
const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
2683
|
+
if (lookup.transient) return;
|
|
2684
|
+
if (lookup.result === "listed") listedCount++;
|
|
2685
|
+
await ctx.collections.dnsblChecks.updateOne(
|
|
2686
|
+
{ target: p.target, list: p.list.host },
|
|
2687
|
+
{
|
|
2688
|
+
$set: {
|
|
2689
|
+
target: p.target,
|
|
2690
|
+
targetKind: p.targetKind,
|
|
2691
|
+
list: p.list.host,
|
|
2692
|
+
listLabel: p.list.label,
|
|
2693
|
+
result: lookup.result,
|
|
2694
|
+
returnCodes: lookup.returnCodes,
|
|
2695
|
+
errorMessage: lookup.errorMessage,
|
|
2696
|
+
runAt: /* @__PURE__ */ new Date()
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
{ upsert: true }
|
|
2261
2700
|
);
|
|
2262
|
-
if (reset.modifiedCount === 0) continue;
|
|
2263
|
-
await ctx.queues.send.add("send", { sendId: String(row._id) }, {
|
|
2264
|
-
attempts: ctx.config.sendRetryAttempts,
|
|
2265
|
-
backoff: { type: "exponential", delay: 6e4 }
|
|
2266
|
-
});
|
|
2267
2701
|
}
|
|
2702
|
+
let idx = 0;
|
|
2703
|
+
await Promise.all(
|
|
2704
|
+
Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
|
|
2705
|
+
while (idx < duePairs.length) {
|
|
2706
|
+
const my = idx++;
|
|
2707
|
+
await processPair(duePairs[my]);
|
|
2708
|
+
}
|
|
2709
|
+
})
|
|
2710
|
+
);
|
|
2711
|
+
return { ran: true, totalChecks: duePairs.length, listedCount };
|
|
2268
2712
|
}
|
|
2269
|
-
|
|
2270
|
-
const
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2713
|
+
function collectTargets(ctx, cfg) {
|
|
2714
|
+
const domains = /* @__PURE__ */ new Set();
|
|
2715
|
+
const registry = ctx.config.senderDomains;
|
|
2716
|
+
if (registry) {
|
|
2717
|
+
for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
|
|
2718
|
+
}
|
|
2719
|
+
const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
|
|
2720
|
+
if (fromDomain) domains.add(fromDomain);
|
|
2721
|
+
const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
|
|
2722
|
+
if (txnDomain) domains.add(txnDomain);
|
|
2723
|
+
return {
|
|
2724
|
+
domains: Array.from(domains),
|
|
2725
|
+
ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
function buildQueryName(target, kind, listHost) {
|
|
2729
|
+
if (kind === "ip") {
|
|
2730
|
+
const v = net__default.default.isIP(target);
|
|
2731
|
+
if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
|
|
2732
|
+
if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
|
|
2733
|
+
return null;
|
|
2734
|
+
}
|
|
2735
|
+
const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
|
|
2736
|
+
return `${domain}.${listHost}`;
|
|
2737
|
+
}
|
|
2738
|
+
var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
|
|
2739
|
+
async function queryDnsbl(resolver, query) {
|
|
2740
|
+
try {
|
|
2741
|
+
const records = await resolver.resolve4(query);
|
|
2742
|
+
return interpretRecords(records);
|
|
2743
|
+
} catch (err) {
|
|
2744
|
+
const code = err?.code;
|
|
2745
|
+
if (code === "ENOTFOUND" || code === "ENODATA") {
|
|
2746
|
+
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
2747
|
+
}
|
|
2748
|
+
if (code && TRANSIENT_DNS_CODES.has(code)) {
|
|
2749
|
+
return {
|
|
2750
|
+
transient: true,
|
|
2751
|
+
result: "error",
|
|
2752
|
+
returnCodes: [],
|
|
2753
|
+
errorMessage: String(err?.message ?? err)
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
return {
|
|
2757
|
+
result: "error",
|
|
2758
|
+
returnCodes: [],
|
|
2759
|
+
errorMessage: String(err?.message ?? err)
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
function interpretRecords(records) {
|
|
2764
|
+
if (!records || records.length === 0) {
|
|
2765
|
+
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
2766
|
+
}
|
|
2767
|
+
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
2768
|
+
if (errorish.length === records.length) {
|
|
2769
|
+
return {
|
|
2770
|
+
result: "error",
|
|
2771
|
+
returnCodes: records,
|
|
2772
|
+
errorMessage: `list returned reserved code(s): ${records.join(", ")}`
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
2776
|
+
if (listed.length > 0) {
|
|
2777
|
+
return { result: "listed", returnCodes: records, errorMessage: null };
|
|
2778
|
+
}
|
|
2779
|
+
return { result: "clean", returnCodes: records, errorMessage: null };
|
|
2780
|
+
}
|
|
2781
|
+
function reverseIPv4(ip) {
|
|
2782
|
+
return ip.split(".").reverse().join(".");
|
|
2783
|
+
}
|
|
2784
|
+
function reverseIPv6Nibbles(ip) {
|
|
2785
|
+
const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
|
|
2786
|
+
let normalized = ip;
|
|
2787
|
+
if (v4Match) {
|
|
2788
|
+
const octets = v4Match[1].split(".").map((o) => Number(o));
|
|
2789
|
+
if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
|
|
2790
|
+
const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
|
|
2791
|
+
const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
|
|
2792
|
+
normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
const sides = normalized.split("::");
|
|
2796
|
+
let groups;
|
|
2797
|
+
if (sides.length === 1) {
|
|
2798
|
+
groups = sides[0].split(":");
|
|
2799
|
+
} else {
|
|
2800
|
+
const left = sides[0].split(":").filter(Boolean);
|
|
2801
|
+
const right = sides[1].split(":").filter(Boolean);
|
|
2802
|
+
const missing = 8 - left.length - right.length;
|
|
2803
|
+
groups = [...left, ...Array(missing).fill("0"), ...right];
|
|
2804
|
+
}
|
|
2805
|
+
if (groups.length !== 8) {
|
|
2806
|
+
throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
|
|
2807
|
+
}
|
|
2808
|
+
const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
|
|
2809
|
+
return padded.split("").reverse().join(".");
|
|
2810
|
+
}
|
|
2811
|
+
function registrableDomain(domain) {
|
|
2812
|
+
const parsed = psl__default.default.parse(domain);
|
|
2813
|
+
if ("domain" in parsed && parsed.domain) return parsed.domain;
|
|
2814
|
+
return domain;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// src/server/runner/postmaster.ts
|
|
2818
|
+
var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
2819
|
+
var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
|
|
2820
|
+
var FETCH_TIMEOUT_MS = 15e3;
|
|
2821
|
+
async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
2822
|
+
const ctl = new AbortController();
|
|
2823
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
2824
|
+
try {
|
|
2825
|
+
return await fetcher(url, { ...init, signal: ctl.signal });
|
|
2826
|
+
} finally {
|
|
2827
|
+
clearTimeout(t);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
|
|
2831
|
+
let cached = null;
|
|
2832
|
+
async function getAccessToken() {
|
|
2833
|
+
if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
|
|
2834
|
+
return cached.accessToken;
|
|
2835
|
+
}
|
|
2836
|
+
const body = new URLSearchParams({
|
|
2837
|
+
client_id: cfg.clientId,
|
|
2838
|
+
client_secret: cfg.clientSecret,
|
|
2839
|
+
refresh_token: cfg.refreshToken,
|
|
2840
|
+
grant_type: "refresh_token"
|
|
2841
|
+
});
|
|
2842
|
+
const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
|
|
2843
|
+
method: "POST",
|
|
2844
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2845
|
+
body: body.toString()
|
|
2846
|
+
});
|
|
2847
|
+
if (!res.ok) {
|
|
2848
|
+
throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
|
|
2849
|
+
}
|
|
2850
|
+
const data = await res.json();
|
|
2851
|
+
cached = {
|
|
2852
|
+
accessToken: data.access_token,
|
|
2853
|
+
expiresAt: Date.now() + data.expires_in * 1e3
|
|
2854
|
+
};
|
|
2855
|
+
return cached.accessToken;
|
|
2856
|
+
}
|
|
2857
|
+
async function authedGet(path3) {
|
|
2858
|
+
const token = await getAccessToken();
|
|
2859
|
+
const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path3}`, {
|
|
2860
|
+
method: "GET",
|
|
2861
|
+
headers: { authorization: `Bearer ${token}` }
|
|
2862
|
+
});
|
|
2863
|
+
if (!res.ok) {
|
|
2864
|
+
throw new Error(`Postmaster GET ${path3} failed: ${res.status} ${await safeText(res)}`);
|
|
2865
|
+
}
|
|
2866
|
+
return await res.json();
|
|
2867
|
+
}
|
|
2868
|
+
return {
|
|
2869
|
+
async listDomains() {
|
|
2870
|
+
const data = await authedGet("/domains");
|
|
2871
|
+
return data.domains ?? [];
|
|
2872
|
+
},
|
|
2873
|
+
async getLatestTrafficStats(domain) {
|
|
2874
|
+
const data = await authedGet(
|
|
2875
|
+
`/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
|
|
2876
|
+
);
|
|
2877
|
+
return data.trafficStats?.[0] ?? null;
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
async function safeText(res) {
|
|
2882
|
+
try {
|
|
2883
|
+
return await res.text();
|
|
2884
|
+
} catch {
|
|
2885
|
+
return "<no body>";
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
async function runPostmasterPull(ctx, opts = {}) {
|
|
2889
|
+
const cfg = ctx.config.postmaster;
|
|
2890
|
+
if (!cfg) return { ran: false, reason: "not_configured" };
|
|
2891
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
2892
|
+
if (!opts.force && intervalHours <= 0) {
|
|
2893
|
+
return { ran: false, reason: "disabled" };
|
|
2894
|
+
}
|
|
2895
|
+
const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
|
|
2896
|
+
const allDomains = resolveDomains(ctx, cfg);
|
|
2897
|
+
if (allDomains.length === 0) {
|
|
2898
|
+
return { ran: false, reason: "no_domains" };
|
|
2899
|
+
}
|
|
2900
|
+
let domains = allDomains;
|
|
2901
|
+
if (!opts.force) {
|
|
2902
|
+
const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
|
|
2903
|
+
const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
|
|
2904
|
+
const lastByDomain = /* @__PURE__ */ new Map();
|
|
2905
|
+
for (const s of latest) {
|
|
2906
|
+
const ts = new Date(s.fetchedAt).getTime();
|
|
2907
|
+
const cur = lastByDomain.get(s.domain) ?? 0;
|
|
2908
|
+
if (ts > cur) lastByDomain.set(s.domain, ts);
|
|
2909
|
+
}
|
|
2910
|
+
domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
|
|
2911
|
+
if (domains.length === 0) {
|
|
2912
|
+
return { ran: false, reason: "not_due" };
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
const fetches = await Promise.all(
|
|
2916
|
+
domains.map(async (domain) => {
|
|
2917
|
+
try {
|
|
2918
|
+
const stat = await client.getLatestTrafficStats(domain);
|
|
2919
|
+
return { domain, stat, error: null };
|
|
2920
|
+
} catch (err) {
|
|
2921
|
+
console.error(`mailery: postmaster fetch failed for ${domain}`, err);
|
|
2922
|
+
return { domain, stat: null, error: err };
|
|
2923
|
+
}
|
|
2924
|
+
})
|
|
2925
|
+
);
|
|
2926
|
+
let fetched = 0;
|
|
2927
|
+
const trippedDomains = [];
|
|
2928
|
+
for (const { domain, stat } of fetches) {
|
|
2929
|
+
if (!stat) continue;
|
|
2930
|
+
const snapshot = toSnapshot(domain, stat);
|
|
2931
|
+
if (!snapshot) continue;
|
|
2932
|
+
fetched++;
|
|
2933
|
+
await ctx.collections.postmasterSnapshots.updateOne(
|
|
2934
|
+
{ domain: snapshot.domain, date: snapshot.date },
|
|
2935
|
+
{ $set: snapshot },
|
|
2936
|
+
{ upsert: true }
|
|
2937
|
+
);
|
|
2938
|
+
if (snapshot.domainReputation === "BAD") {
|
|
2939
|
+
const kindsToTrip = kindsForDomain(ctx, domain);
|
|
2940
|
+
for (const kind of kindsToTrip) {
|
|
2941
|
+
const tripped = await tripBucket(ctx, domain, kind, snapshot);
|
|
2942
|
+
if (tripped) trippedDomains.push(`${domain}|${kind}`);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
return { ran: true, fetched, trippedDomains };
|
|
2947
|
+
}
|
|
2948
|
+
function kindsForDomain(ctx, domain) {
|
|
2949
|
+
const registry = ctx.config.senderDomains ?? {};
|
|
2950
|
+
const entry = registry[domain.toLowerCase()];
|
|
2951
|
+
if (!entry) return [];
|
|
2952
|
+
if (entry.kind === "both") return ["marketing", "transactional"];
|
|
2953
|
+
return [entry.kind];
|
|
2954
|
+
}
|
|
2955
|
+
function resolveDomains(ctx, cfg) {
|
|
2956
|
+
if (cfg.domains && cfg.domains.length > 0) {
|
|
2957
|
+
return cfg.domains.map((d) => d.toLowerCase());
|
|
2958
|
+
}
|
|
2959
|
+
const out = /* @__PURE__ */ new Set();
|
|
2960
|
+
const registry = ctx.config.senderDomains;
|
|
2961
|
+
if (registry) {
|
|
2962
|
+
for (const d of Object.keys(registry)) out.add(d.toLowerCase());
|
|
2963
|
+
}
|
|
2964
|
+
const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
|
|
2965
|
+
if (f) out.add(f);
|
|
2966
|
+
const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
|
|
2967
|
+
if (t) out.add(t);
|
|
2968
|
+
return Array.from(out);
|
|
2969
|
+
}
|
|
2970
|
+
function toSnapshot(domain, stat) {
|
|
2971
|
+
const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
|
|
2972
|
+
if (!m) {
|
|
2973
|
+
console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
|
|
2974
|
+
return null;
|
|
2975
|
+
}
|
|
2976
|
+
const yyyymmdd = m[1];
|
|
2977
|
+
const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
|
2978
|
+
return {
|
|
2979
|
+
domain,
|
|
2980
|
+
date,
|
|
2981
|
+
domainReputation: stat.domainReputation ?? null,
|
|
2982
|
+
ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
|
|
2983
|
+
reputation: r.reputation,
|
|
2984
|
+
ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
|
|
2985
|
+
})) : null,
|
|
2986
|
+
userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
|
|
2987
|
+
spfSuccessRatio: stat.spfSuccessRatio ?? null,
|
|
2988
|
+
dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
|
|
2989
|
+
dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
|
|
2990
|
+
outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
|
|
2991
|
+
inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
|
|
2992
|
+
deliveryErrors: stat.deliveryErrors ?? null,
|
|
2993
|
+
spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
|
|
2994
|
+
fetchedAt: /* @__PURE__ */ new Date()
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
async function tripBucket(ctx, domain, kind, snapshot) {
|
|
2998
|
+
const id = healthBucketId(domain, kind);
|
|
2999
|
+
const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
|
|
3000
|
+
const prior = await ctx.collections.health.findOne({ _id: id });
|
|
3001
|
+
if (prior?.status === "tripped") {
|
|
3002
|
+
return false;
|
|
3003
|
+
}
|
|
3004
|
+
await ctx.collections.health.updateOne(
|
|
3005
|
+
{ _id: id },
|
|
3006
|
+
{
|
|
3007
|
+
$set: {
|
|
3008
|
+
status: "tripped",
|
|
3009
|
+
trippedAt: /* @__PURE__ */ new Date(),
|
|
3010
|
+
trippedReason: reason,
|
|
3011
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3012
|
+
},
|
|
3013
|
+
$setOnInsert: {
|
|
3014
|
+
_id: id,
|
|
3015
|
+
senderDomain: domain,
|
|
3016
|
+
kind,
|
|
3017
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
3018
|
+
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
3019
|
+
manuallyResumedAt: null,
|
|
3020
|
+
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
3021
|
+
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
3022
|
+
}
|
|
3023
|
+
},
|
|
3024
|
+
{ upsert: true }
|
|
3025
|
+
);
|
|
3026
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
3027
|
+
try {
|
|
3028
|
+
await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
|
|
3029
|
+
} catch {
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return true;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// src/server/runner/snds.ts
|
|
3036
|
+
var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
|
|
3037
|
+
var FETCH_TIMEOUT_MS2 = 3e4;
|
|
3038
|
+
async function runSndsPull(ctx, opts = {}) {
|
|
3039
|
+
const cfg = ctx.config.snds;
|
|
3040
|
+
if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
|
|
3041
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
3042
|
+
if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
|
|
3043
|
+
if (!opts.force) {
|
|
3044
|
+
const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
|
|
3045
|
+
if (latest[0]) {
|
|
3046
|
+
const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
|
|
3047
|
+
if (ageMs < intervalHours * 60 * 60 * 1e3) {
|
|
3048
|
+
return { ran: false, reason: "not_due" };
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
const fetcher = opts.fetcher ?? globalThis.fetch;
|
|
3053
|
+
const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
|
|
3054
|
+
const ctl = new AbortController();
|
|
3055
|
+
const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
|
|
3056
|
+
let res;
|
|
3057
|
+
try {
|
|
3058
|
+
res = await fetcher(url, { method: "GET", signal: ctl.signal });
|
|
3059
|
+
} catch (err) {
|
|
3060
|
+
throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
|
|
3061
|
+
} finally {
|
|
3062
|
+
clearTimeout(t);
|
|
3063
|
+
}
|
|
3064
|
+
if (!res.ok) {
|
|
3065
|
+
throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
|
|
3066
|
+
}
|
|
3067
|
+
const csv = await res.text();
|
|
3068
|
+
const rows = parseSndsCsv(csv);
|
|
3069
|
+
const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
|
|
3070
|
+
const now = /* @__PURE__ */ new Date();
|
|
3071
|
+
const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
|
|
3072
|
+
updateOne: {
|
|
3073
|
+
filter: { ip: row.ip, activityStart: row.activityStart },
|
|
3074
|
+
update: { $set: { ...row, fetchedAt: now } },
|
|
3075
|
+
upsert: true
|
|
3076
|
+
}
|
|
3077
|
+
}));
|
|
3078
|
+
let persisted = 0;
|
|
3079
|
+
if (ops.length > 0) {
|
|
3080
|
+
const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
|
|
3081
|
+
persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
|
|
3082
|
+
}
|
|
3083
|
+
return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
|
|
3084
|
+
}
|
|
3085
|
+
function redactKey(s, key) {
|
|
3086
|
+
if (!key) return s;
|
|
3087
|
+
return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
|
|
3088
|
+
}
|
|
3089
|
+
async function safeText2(res) {
|
|
3090
|
+
try {
|
|
3091
|
+
return await res.text();
|
|
3092
|
+
} catch {
|
|
3093
|
+
return "<no body>";
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
function parseSndsCsv(csv) {
|
|
3097
|
+
const out = [];
|
|
3098
|
+
for (const raw of csv.split(/\r?\n/)) {
|
|
3099
|
+
if (!raw.trim()) continue;
|
|
3100
|
+
const fields = splitCsvRow(raw);
|
|
3101
|
+
if (fields.length < 7) continue;
|
|
3102
|
+
const parsed = parseRow(fields);
|
|
3103
|
+
if (parsed) out.push(parsed);
|
|
3104
|
+
}
|
|
3105
|
+
return out;
|
|
3106
|
+
}
|
|
3107
|
+
function splitCsvRow(row) {
|
|
3108
|
+
const out = [];
|
|
3109
|
+
let cur = "";
|
|
3110
|
+
let inQuotes = false;
|
|
3111
|
+
for (let i = 0; i < row.length; i++) {
|
|
3112
|
+
const ch = row[i];
|
|
3113
|
+
if (inQuotes) {
|
|
3114
|
+
if (ch === '"') {
|
|
3115
|
+
if (row[i + 1] === '"') {
|
|
3116
|
+
cur += '"';
|
|
3117
|
+
i++;
|
|
3118
|
+
} else {
|
|
3119
|
+
inQuotes = false;
|
|
3120
|
+
}
|
|
3121
|
+
} else {
|
|
3122
|
+
cur += ch;
|
|
3123
|
+
}
|
|
3124
|
+
} else if (ch === '"') {
|
|
3125
|
+
inQuotes = true;
|
|
3126
|
+
} else if (ch === ",") {
|
|
3127
|
+
out.push(cur);
|
|
3128
|
+
cur = "";
|
|
3129
|
+
} else {
|
|
3130
|
+
cur += ch;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
out.push(cur);
|
|
3134
|
+
return out.map((f) => f.trim());
|
|
3135
|
+
}
|
|
3136
|
+
function parseRow(fields) {
|
|
3137
|
+
const [
|
|
3138
|
+
ip,
|
|
3139
|
+
activityStart,
|
|
3140
|
+
activityEnd,
|
|
3141
|
+
rcptCommands,
|
|
3142
|
+
dataCommands,
|
|
3143
|
+
messageRecipients,
|
|
3144
|
+
filterResult,
|
|
3145
|
+
complaintRate,
|
|
3146
|
+
trapMessageCount,
|
|
3147
|
+
sampleHelo,
|
|
3148
|
+
sampleMailFrom
|
|
3149
|
+
] = fields;
|
|
3150
|
+
const start = parseSndsDate(activityStart ?? "");
|
|
3151
|
+
const end = parseSndsDate(activityEnd ?? "");
|
|
3152
|
+
if (!ip || !start || !end) return null;
|
|
3153
|
+
return {
|
|
3154
|
+
ip,
|
|
3155
|
+
activityStart: start,
|
|
3156
|
+
activityEnd: end,
|
|
3157
|
+
rcptCommands: toInt(rcptCommands),
|
|
3158
|
+
dataCommands: toInt(dataCommands),
|
|
3159
|
+
messageRecipients: toInt(messageRecipients),
|
|
3160
|
+
filterResult: normalizeFilter(filterResult ?? ""),
|
|
3161
|
+
complaintRate: parseComplaintRate(complaintRate ?? ""),
|
|
3162
|
+
trapMessageCount: toInt(trapMessageCount),
|
|
3163
|
+
sampleHelo: sampleHelo ? sampleHelo : null,
|
|
3164
|
+
sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
function toInt(s) {
|
|
3168
|
+
if (!s) return 0;
|
|
3169
|
+
const n = Number(s.replace(/[^\d-]/g, ""));
|
|
3170
|
+
return Number.isFinite(n) ? n : 0;
|
|
3171
|
+
}
|
|
3172
|
+
function normalizeFilter(s) {
|
|
3173
|
+
const u = s.toUpperCase().trim();
|
|
3174
|
+
if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
|
|
3175
|
+
return "UNKNOWN";
|
|
3176
|
+
}
|
|
3177
|
+
function parseComplaintRate(s) {
|
|
3178
|
+
const trimmed = s.trim();
|
|
3179
|
+
if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
|
|
3180
|
+
const cleaned = trimmed.replace(/%/g, "");
|
|
3181
|
+
let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
3182
|
+
if (m) return Number(m[1]) / 100;
|
|
3183
|
+
m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
3184
|
+
if (m) return Number(m[1]) / 100;
|
|
3185
|
+
m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
3186
|
+
if (m) return Number(m[2]) / 100;
|
|
3187
|
+
m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
3188
|
+
if (m) return Number(m[1]) / 100;
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
function parseSndsDate(s) {
|
|
3192
|
+
const trimmed = s.trim();
|
|
3193
|
+
if (!trimmed) return null;
|
|
3194
|
+
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
|
|
3195
|
+
if (m) {
|
|
3196
|
+
const month = Number(m[1]) - 1;
|
|
3197
|
+
const day = Number(m[2]);
|
|
3198
|
+
const year = Number(m[3]);
|
|
3199
|
+
let hour = Number(m[4]);
|
|
3200
|
+
const minute = Number(m[5]);
|
|
3201
|
+
const second = Number(m[6] ?? 0);
|
|
3202
|
+
const meridiem = m[7]?.toUpperCase();
|
|
3203
|
+
if (meridiem === "PM" && hour < 12) hour += 12;
|
|
3204
|
+
if (meridiem === "AM" && hour === 12) hour = 0;
|
|
3205
|
+
const wallMs = Date.UTC(year, month, day, hour, minute, second);
|
|
3206
|
+
if (!Number.isFinite(wallMs)) return null;
|
|
3207
|
+
const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
|
|
3208
|
+
return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
|
|
3209
|
+
}
|
|
3210
|
+
const d = new Date(trimmed);
|
|
3211
|
+
if (!isNaN(d.getTime())) return d;
|
|
3212
|
+
return null;
|
|
3213
|
+
}
|
|
3214
|
+
function isUsPacificDst(year, month0, day) {
|
|
3215
|
+
if (month0 > 2 && month0 < 10) return true;
|
|
3216
|
+
if (month0 < 2 || month0 > 10) return false;
|
|
3217
|
+
if (month0 === 2) {
|
|
3218
|
+
const dst2 = nthSundayOfMonth(year, 2, 2);
|
|
3219
|
+
return day >= dst2;
|
|
3220
|
+
}
|
|
3221
|
+
const dst = nthSundayOfMonth(year, 10, 1);
|
|
3222
|
+
return day < dst;
|
|
3223
|
+
}
|
|
3224
|
+
function nthSundayOfMonth(year, month0, n) {
|
|
3225
|
+
const first = new Date(Date.UTC(year, month0, 1));
|
|
3226
|
+
const dayOfWeek = first.getUTCDay();
|
|
3227
|
+
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
3228
|
+
return firstSunday + (n - 1) * 7;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
// src/server/runner/dmarc.ts
|
|
3232
|
+
var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
|
|
3233
|
+
function assertSafeZipEntryName(rawName) {
|
|
3234
|
+
if (typeof rawName !== "string" || rawName.length === 0) {
|
|
3235
|
+
throw new Error(`zip entry has unsafe path: <empty>`);
|
|
3236
|
+
}
|
|
3237
|
+
if (rawName.includes("\0")) {
|
|
3238
|
+
throw new Error(`zip entry has unsafe path: contains null byte`);
|
|
3239
|
+
}
|
|
3240
|
+
if (rawName.startsWith("/") || rawName.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(rawName)) {
|
|
3241
|
+
throw new Error(`zip entry has unsafe path: ${rawName}`);
|
|
3242
|
+
}
|
|
3243
|
+
const parts = rawName.split(/[/\\]/);
|
|
3244
|
+
if (parts.some((p) => p === "..")) {
|
|
3245
|
+
throw new Error(`zip entry has unsafe path: ${rawName}`);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
async function extractDmarcXmls(buffer, filename) {
|
|
3249
|
+
const lower = (filename ?? "").toLowerCase();
|
|
3250
|
+
const zlib = await import('zlib');
|
|
3251
|
+
if (lower.endsWith(".gz")) {
|
|
3252
|
+
const xml = await new Promise((resolve, reject) => {
|
|
3253
|
+
const chunks = [];
|
|
3254
|
+
let bytes = 0;
|
|
3255
|
+
const stream = zlib.createGunzip();
|
|
3256
|
+
stream.on("data", (chunk) => {
|
|
3257
|
+
bytes += chunk.length;
|
|
3258
|
+
if (bytes > MAX_DECOMPRESSED_BYTES) {
|
|
3259
|
+
stream.destroy();
|
|
3260
|
+
reject(new Error(`DMARC gzip exceeds ${MAX_DECOMPRESSED_BYTES} bytes (decompression bomb?)`));
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
chunks.push(chunk);
|
|
3264
|
+
});
|
|
3265
|
+
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
3266
|
+
stream.on("error", reject);
|
|
3267
|
+
stream.end(buffer);
|
|
3268
|
+
});
|
|
3269
|
+
return [xml];
|
|
3270
|
+
}
|
|
3271
|
+
if (lower.endsWith(".zip")) {
|
|
3272
|
+
const { default: AdmZip } = await import('adm-zip');
|
|
3273
|
+
const zip = new AdmZip(buffer);
|
|
3274
|
+
const entries = zip.getEntries().filter((e) => !e.isDirectory);
|
|
3275
|
+
if (entries.length === 0) throw new Error("zip contains no files");
|
|
3276
|
+
let total = 0;
|
|
3277
|
+
const pieces = [];
|
|
3278
|
+
for (const entry of entries) {
|
|
3279
|
+
assertSafeZipEntryName(entry.entryName);
|
|
3280
|
+
const declaredSize = entry.header?.size;
|
|
3281
|
+
if (typeof declaredSize !== "number" || declaredSize <= 0) {
|
|
3282
|
+
throw new Error(`zip entry ${entry.entryName} has no declared size (refusing to decompress)`);
|
|
3283
|
+
}
|
|
3284
|
+
if (declaredSize > MAX_DECOMPRESSED_BYTES || total + declaredSize > MAX_DECOMPRESSED_BYTES) {
|
|
3285
|
+
throw new Error(`DMARC zip exceeds ${MAX_DECOMPRESSED_BYTES} bytes (decompression bomb?)`);
|
|
3286
|
+
}
|
|
3287
|
+
const data = entry.getData();
|
|
3288
|
+
total += data.length;
|
|
3289
|
+
if (total > MAX_DECOMPRESSED_BYTES) {
|
|
3290
|
+
throw new Error(`DMARC zip decompressed payload exceeds ${MAX_DECOMPRESSED_BYTES} bytes (forged header?)`);
|
|
3291
|
+
}
|
|
3292
|
+
if (declaredSize > 10 * 1024 && data.length > declaredSize * 64 + 1024) {
|
|
3293
|
+
throw new Error(`DMARC zip entry ${entry.entryName} decompressed size (${data.length}) far exceeds declared (${declaredSize})`);
|
|
3294
|
+
}
|
|
3295
|
+
pieces.push(data.toString("utf8"));
|
|
3296
|
+
}
|
|
3297
|
+
return pieces;
|
|
3298
|
+
}
|
|
3299
|
+
if (buffer.length > MAX_DECOMPRESSED_BYTES) {
|
|
3300
|
+
throw new Error(`DMARC payload exceeds ${MAX_DECOMPRESSED_BYTES} bytes`);
|
|
3301
|
+
}
|
|
3302
|
+
return [buffer.toString("utf8")];
|
|
3303
|
+
}
|
|
3304
|
+
function parseDmarcReport(xml) {
|
|
3305
|
+
const { XMLParser } = __require("fast-xml-parser");
|
|
3306
|
+
const parser = new XMLParser({
|
|
3307
|
+
ignoreAttributes: false,
|
|
3308
|
+
parseAttributeValue: false,
|
|
3309
|
+
parseTagValue: false,
|
|
3310
|
+
trimValues: true,
|
|
3311
|
+
// RFC 7489 specifies lowercase snake_case tag names, but some
|
|
3312
|
+
// less-careful receivers emit CamelCase. Normalize before lookup.
|
|
3313
|
+
transformTagName: (t) => t.toLowerCase()
|
|
3314
|
+
});
|
|
3315
|
+
const root = parser.parse(xml);
|
|
3316
|
+
const fb = root?.feedback;
|
|
3317
|
+
if (!fb) throw new Error("DMARC XML missing <feedback> root");
|
|
3318
|
+
const meta = fb.report_metadata ?? {};
|
|
3319
|
+
const policy = fb.policy_published ?? {};
|
|
3320
|
+
const reportId = String(meta.report_id ?? "").trim();
|
|
3321
|
+
const orgName = String(meta.org_name ?? "").trim();
|
|
3322
|
+
const email = String(meta.email ?? "").trim();
|
|
3323
|
+
const domain = String(policy.domain ?? "").trim().toLowerCase();
|
|
3324
|
+
const policyP = policy.p ?? "none";
|
|
3325
|
+
const rawPct = Number(policy.pct ?? 100);
|
|
3326
|
+
const policyPct = Number.isFinite(rawPct) ? Math.max(0, Math.min(100, rawPct)) : 100;
|
|
3327
|
+
if (!reportId) throw new Error("DMARC XML missing report_id");
|
|
3328
|
+
if (!domain) throw new Error("DMARC XML missing policy_published.domain");
|
|
3329
|
+
const range = meta.date_range ?? {};
|
|
3330
|
+
const begin = secondsToDate(range.begin);
|
|
3331
|
+
const end = secondsToDate(range.end);
|
|
3332
|
+
if (!begin || !end) throw new Error("DMARC XML missing date_range");
|
|
3333
|
+
const records = toArray(fb.record);
|
|
3334
|
+
const failures = [];
|
|
3335
|
+
let totalMessages = 0;
|
|
3336
|
+
let passCount = 0;
|
|
3337
|
+
let failCount = 0;
|
|
3338
|
+
const midMs = Math.floor((begin.getTime() + end.getTime()) / 2);
|
|
3339
|
+
const day = new Date(midMs).toISOString().slice(0, 10);
|
|
3340
|
+
for (const rec of records) {
|
|
3341
|
+
const row = rec?.row ?? {};
|
|
3342
|
+
const count = Number(row.count ?? 0) || 0;
|
|
3343
|
+
totalMessages += count;
|
|
3344
|
+
const evald = row.policy_evaluated ?? {};
|
|
3345
|
+
const dkim = evald.dkim ?? "none";
|
|
3346
|
+
const spf = evald.spf ?? "none";
|
|
3347
|
+
const aligned = dkim === "pass" || spf === "pass";
|
|
3348
|
+
if (aligned) {
|
|
3349
|
+
passCount += count;
|
|
3350
|
+
continue;
|
|
3351
|
+
}
|
|
3352
|
+
failCount += count;
|
|
3353
|
+
const sourceIp = String(row.source_ip ?? "").trim();
|
|
3354
|
+
if (!sourceIp) continue;
|
|
3355
|
+
const headerFrom = String(rec?.identifiers?.header_from ?? "").toLowerCase();
|
|
3356
|
+
const dispositionApplied = String(evald.disposition ?? "none");
|
|
3357
|
+
failures.push({
|
|
3358
|
+
reportId,
|
|
3359
|
+
domain,
|
|
3360
|
+
sourceIp,
|
|
3361
|
+
count,
|
|
3362
|
+
headerFrom,
|
|
3363
|
+
dkimResult: dkim,
|
|
3364
|
+
spfResult: spf,
|
|
3365
|
+
dispositionApplied,
|
|
3366
|
+
day
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
return {
|
|
3370
|
+
report: {
|
|
3371
|
+
reportId,
|
|
3372
|
+
orgName,
|
|
3373
|
+
email,
|
|
3374
|
+
domain,
|
|
3375
|
+
policyP,
|
|
3376
|
+
policyPct,
|
|
3377
|
+
rangeStart: begin,
|
|
3378
|
+
rangeEnd: end,
|
|
3379
|
+
totalMessages,
|
|
3380
|
+
passCount,
|
|
3381
|
+
failCount
|
|
3382
|
+
},
|
|
3383
|
+
failures
|
|
3384
|
+
};
|
|
3385
|
+
}
|
|
3386
|
+
function secondsToDate(v) {
|
|
3387
|
+
if (v === void 0 || v === null) return null;
|
|
3388
|
+
const n = typeof v === "number" ? v : Number(String(v));
|
|
3389
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
3390
|
+
return new Date(n * 1e3);
|
|
3391
|
+
}
|
|
3392
|
+
function toArray(v) {
|
|
3393
|
+
if (v === void 0 || v === null) return [];
|
|
3394
|
+
return Array.isArray(v) ? v : [v];
|
|
3395
|
+
}
|
|
3396
|
+
async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
3397
|
+
const xmls = await extractDmarcXmls(buffer, filename);
|
|
3398
|
+
if (xmls.length === 0) throw new Error("attachment contained no XML payload");
|
|
3399
|
+
const results = [];
|
|
3400
|
+
let firstError = null;
|
|
3401
|
+
for (const xml of xmls) {
|
|
3402
|
+
try {
|
|
3403
|
+
const parsed = parseDmarcReport(xml);
|
|
3404
|
+
results.push(await ingestParsedDmarcReport(ctx, parsed));
|
|
3405
|
+
} catch (err) {
|
|
3406
|
+
if (!firstError) firstError = err;
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
if (results.length === 0) {
|
|
3410
|
+
throw firstError ?? new Error("no parseable DMARC reports in attachment");
|
|
3411
|
+
}
|
|
3412
|
+
return { ...results[0], additionalReports: results.length - 1 };
|
|
3413
|
+
}
|
|
3414
|
+
async function ingestParsedDmarcReport(ctx, parsed) {
|
|
3415
|
+
const now = /* @__PURE__ */ new Date();
|
|
3416
|
+
const { report, failures } = parsed;
|
|
3417
|
+
let duplicate = false;
|
|
3418
|
+
try {
|
|
3419
|
+
await ctx.collections.dmarcReports.insertOne({ ...report, receivedAt: now });
|
|
3420
|
+
} catch (err) {
|
|
3421
|
+
if (err?.code === 11e3) {
|
|
3422
|
+
duplicate = true;
|
|
3423
|
+
} else {
|
|
3424
|
+
throw err;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
if (!duplicate && failures.length > 0) {
|
|
3428
|
+
try {
|
|
3429
|
+
await ctx.collections.dmarcFailures.insertMany(
|
|
3430
|
+
failures.map((f) => ({ ...f, receivedAt: now })),
|
|
3431
|
+
{ ordered: false }
|
|
3432
|
+
);
|
|
3433
|
+
} catch (err) {
|
|
3434
|
+
const writeErrors = err?.writeErrors;
|
|
3435
|
+
if (Array.isArray(writeErrors)) {
|
|
3436
|
+
const nonDup = writeErrors.find((w) => w?.code !== 11e3);
|
|
3437
|
+
if (nonDup) throw err;
|
|
3438
|
+
} else if (err?.code !== 11e3) {
|
|
3439
|
+
throw err;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
return {
|
|
3444
|
+
ok: true,
|
|
3445
|
+
reportId: report.reportId,
|
|
3446
|
+
domain: report.domain,
|
|
3447
|
+
rangeStart: report.rangeStart,
|
|
3448
|
+
rangeEnd: report.rangeEnd,
|
|
3449
|
+
totalMessages: report.totalMessages,
|
|
3450
|
+
passCount: report.passCount,
|
|
3451
|
+
failCount: report.failCount,
|
|
3452
|
+
duplicate
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
var _lastPruneAt = 0;
|
|
3456
|
+
var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
|
|
3457
|
+
async function pruneDmarcFailures(ctx, opts = {}) {
|
|
3458
|
+
const cfg = ctx.config.dmarc;
|
|
3459
|
+
const days = cfg?.retentionDays ?? 90;
|
|
3460
|
+
if (days <= 0) return 0;
|
|
3461
|
+
if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
|
|
3462
|
+
_lastPruneAt = Date.now();
|
|
3463
|
+
const cutoff = new Date(Date.now() - days * 864e5);
|
|
3464
|
+
const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
|
|
3465
|
+
return r.deletedCount ?? 0;
|
|
3466
|
+
}
|
|
3467
|
+
async function resolveSourceTags(ctx) {
|
|
3468
|
+
const out = /* @__PURE__ */ new Map();
|
|
3469
|
+
for (const t of ctx.config.dmarc?.knownSources ?? []) {
|
|
3470
|
+
out.set(t.ip, { ip: t.ip, label: t.label, ignored: !!t.ignored, source: "config" });
|
|
3471
|
+
}
|
|
3472
|
+
const dbTags = await ctx.collections.dmarcSourceTags.find({}).toArray();
|
|
3473
|
+
for (const t of dbTags) {
|
|
3474
|
+
out.set(t.ip, { ip: t.ip, label: t.label, ignored: t.ignored, source: "db" });
|
|
3475
|
+
}
|
|
3476
|
+
return out;
|
|
3477
|
+
}
|
|
3478
|
+
function suggestPolicyProgression(input) {
|
|
3479
|
+
const { reports, failures, knownSourceIps, ignoredSourceIps, currentPolicy, currentPct } = input;
|
|
3480
|
+
if (currentPolicy === "reject") return null;
|
|
3481
|
+
const since30 = Date.now() - 30 * 864e5;
|
|
3482
|
+
const recentReports = reports.filter((r) => r.rangeEnd.getTime() >= since30);
|
|
3483
|
+
const totalMsgs = recentReports.reduce((acc, r) => acc + r.passCount + r.failCount, 0);
|
|
3484
|
+
const totalPass = recentReports.reduce((acc, r) => acc + r.passCount, 0);
|
|
3485
|
+
if (recentReports.length < 30) return null;
|
|
3486
|
+
if (totalMsgs < 1e3) return null;
|
|
3487
|
+
const untaggedFailures = failures.filter(
|
|
3488
|
+
(f) => f.receivedAt.getTime() >= since30 && !knownSourceIps.has(f.sourceIp) && !ignoredSourceIps.has(f.sourceIp)
|
|
3489
|
+
);
|
|
3490
|
+
if (untaggedFailures.length > 0) return null;
|
|
3491
|
+
const alignmentRate = totalMsgs === 0 ? 0 : totalPass / totalMsgs;
|
|
3492
|
+
if (currentPolicy === null || currentPolicy === "none") {
|
|
3493
|
+
if (alignmentRate >= 0.99) {
|
|
3494
|
+
return {
|
|
3495
|
+
policy: "quarantine",
|
|
3496
|
+
pct: 10,
|
|
3497
|
+
reason: `${(alignmentRate * 100).toFixed(2)}% alignment over ${recentReports.length} reports / ${totalMsgs.toLocaleString()} messages, all known sources tagged.`
|
|
3498
|
+
};
|
|
3499
|
+
}
|
|
3500
|
+
return null;
|
|
3501
|
+
}
|
|
3502
|
+
if (currentPolicy === "quarantine") {
|
|
3503
|
+
const pct = currentPct ?? 100;
|
|
3504
|
+
if (alignmentRate < 0.995) return null;
|
|
3505
|
+
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.` };
|
|
3506
|
+
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.` };
|
|
3507
|
+
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.` };
|
|
3508
|
+
if (alignmentRate >= 0.999) {
|
|
3509
|
+
return { policy: "reject", pct: 100, reason: `Alignment at ${(alignmentRate * 100).toFixed(3)}% with policy=quarantine pct=100 \u2014 safe to move to p=reject.` };
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
return null;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
// src/server/runner/tick.ts
|
|
3516
|
+
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3517
|
+
async function runTick(ctx) {
|
|
3518
|
+
try {
|
|
3519
|
+
await ctx.collections.health.updateOne(
|
|
3520
|
+
{ _id: HEALTH_AGG_ID },
|
|
3521
|
+
{
|
|
3522
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() },
|
|
3523
|
+
$setOnInsert: {
|
|
3524
|
+
_id: HEALTH_AGG_ID,
|
|
3525
|
+
senderDomain: null,
|
|
3526
|
+
kind: null,
|
|
3527
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
3528
|
+
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
3529
|
+
status: "healthy",
|
|
3530
|
+
trippedAt: null,
|
|
3531
|
+
trippedReason: null,
|
|
3532
|
+
manuallyResumedAt: null,
|
|
3533
|
+
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
3534
|
+
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
3535
|
+
}
|
|
3536
|
+
},
|
|
3537
|
+
{ upsert: true }
|
|
3538
|
+
);
|
|
3539
|
+
} catch (err) {
|
|
3540
|
+
console.error("mailery: heartbeat write failed", err);
|
|
3541
|
+
}
|
|
3542
|
+
await processNewlyFiredEventTriggers(ctx).catch((err) => {
|
|
3543
|
+
console.error("mailery: triggers scan failed", err);
|
|
3544
|
+
});
|
|
3545
|
+
await sweepStrandedFlowRuns(ctx).catch((err) => {
|
|
3546
|
+
console.error("mailery: sweep failed", err);
|
|
3547
|
+
});
|
|
3548
|
+
await sweepStrandedSends(ctx).catch((err) => {
|
|
3549
|
+
console.error("mailery: stranded-send sweep failed", err);
|
|
3550
|
+
});
|
|
3551
|
+
await drainOutbox(ctx).catch((err) => {
|
|
3552
|
+
console.error("mailery: outbox drain failed", err);
|
|
3553
|
+
});
|
|
3554
|
+
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3555
|
+
console.error("mailery: broadcast dispatch failed", err);
|
|
3556
|
+
});
|
|
3557
|
+
await evaluateHealth(ctx).catch((err) => {
|
|
3558
|
+
console.error("mailery: health evaluation failed", err);
|
|
3559
|
+
});
|
|
3560
|
+
await promoteSoftBounces(ctx).catch((err) => {
|
|
3561
|
+
console.error("mailery: soft-bounce promotion failed", err);
|
|
3562
|
+
});
|
|
3563
|
+
await Promise.all([
|
|
3564
|
+
runDnsblChecks(ctx).catch((err) => {
|
|
3565
|
+
console.error("mailery: dnsbl checks failed", err);
|
|
3566
|
+
}),
|
|
3567
|
+
runPostmasterPull(ctx).catch((err) => {
|
|
3568
|
+
console.error("mailery: postmaster pull failed", err);
|
|
3569
|
+
}),
|
|
3570
|
+
runSndsPull(ctx).catch((err) => {
|
|
3571
|
+
console.error("mailery: snds pull failed", err);
|
|
3572
|
+
}),
|
|
3573
|
+
pruneDmarcFailures(ctx).catch((err) => {
|
|
3574
|
+
console.error("mailery: dmarc prune failed", err);
|
|
3575
|
+
})
|
|
3576
|
+
]);
|
|
3577
|
+
}
|
|
3578
|
+
async function sweepStrandedSends(ctx) {
|
|
3579
|
+
const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
|
|
3580
|
+
const cursor = ctx.collections.sends.find(
|
|
3581
|
+
{ status: "sending", updatedAt: { $lt: cutoff } },
|
|
3582
|
+
{ projection: { _id: 1 } }
|
|
3583
|
+
).limit(500);
|
|
3584
|
+
for await (const row of cursor) {
|
|
3585
|
+
const reset = await ctx.collections.sends.updateOne(
|
|
3586
|
+
{ _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
|
|
3587
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3588
|
+
);
|
|
3589
|
+
if (reset.modifiedCount === 0) continue;
|
|
3590
|
+
await ctx.queues.send.add("send", { sendId: String(row._id) }, {
|
|
3591
|
+
attempts: ctx.config.sendRetryAttempts,
|
|
3592
|
+
backoff: { type: "exponential", delay: 6e4 }
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
async function drainOutbox(ctx) {
|
|
3597
|
+
const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
|
|
3598
|
+
for (const row of batch) {
|
|
3599
|
+
try {
|
|
3600
|
+
if (row.payload.type === "event") {
|
|
3601
|
+
try {
|
|
3602
|
+
await ctx.collections.events.insertOne({
|
|
3603
|
+
externalId: row.payload.data.externalId,
|
|
3604
|
+
name: row.payload.data.name,
|
|
2278
3605
|
properties: row.payload.data.properties ?? {},
|
|
2279
3606
|
dedupeKey: row.payload.dedupeKey,
|
|
2280
3607
|
occurredAt: row.payload.data.occurredAt ?? /* @__PURE__ */ new Date(),
|
|
@@ -2304,6 +3631,10 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
2304
3631
|
}
|
|
2305
3632
|
|
|
2306
3633
|
// src/server/runner/webhook.ts
|
|
3634
|
+
function dimsFromSend(send) {
|
|
3635
|
+
if (!send) return null;
|
|
3636
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
3637
|
+
}
|
|
2307
3638
|
async function applyWebhookEvent(event, ctx) {
|
|
2308
3639
|
const send = await ctx.collections.sends.findOne(
|
|
2309
3640
|
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
@@ -2317,7 +3648,7 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
2317
3648
|
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
2318
3649
|
);
|
|
2319
3650
|
}
|
|
2320
|
-
await recordHealthCounter(ctx, "delivered");
|
|
3651
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
2321
3652
|
break;
|
|
2322
3653
|
case "open":
|
|
2323
3654
|
if (send) {
|
|
@@ -2372,8 +3703,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
2372
3703
|
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2373
3704
|
);
|
|
2374
3705
|
}
|
|
2375
|
-
|
|
2376
|
-
|
|
3706
|
+
{
|
|
3707
|
+
const dims = dimsFromSend(send);
|
|
3708
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
3709
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
3710
|
+
}
|
|
2377
3711
|
break;
|
|
2378
3712
|
}
|
|
2379
3713
|
case "complaint":
|
|
@@ -2389,7 +3723,7 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
2389
3723
|
{ emailAtSubscribe: event.email },
|
|
2390
3724
|
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2391
3725
|
);
|
|
2392
|
-
await recordHealthCounter(ctx, "complained");
|
|
3726
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
2393
3727
|
break;
|
|
2394
3728
|
case "unsubscribe":
|
|
2395
3729
|
if (send) {
|
|
@@ -2455,10 +3789,12 @@ var Mailer = class _Mailer {
|
|
|
2455
3789
|
db: this.db,
|
|
2456
3790
|
collections: this.collections,
|
|
2457
3791
|
adapter: this.adapter,
|
|
3792
|
+
varsAdapter: this.config.varsAdapter,
|
|
2458
3793
|
providers: this.providers,
|
|
2459
3794
|
queues: this.queues,
|
|
2460
3795
|
config: this.config,
|
|
2461
|
-
handlebarsHelpers: this.config.handlebarsHelpers
|
|
3796
|
+
handlebarsHelpers: this.config.handlebarsHelpers,
|
|
3797
|
+
audit: (entry) => this.audit(entry)
|
|
2462
3798
|
};
|
|
2463
3799
|
}
|
|
2464
3800
|
/**
|
|
@@ -2533,6 +3869,10 @@ var Mailer = class _Mailer {
|
|
|
2533
3869
|
if (!config.providers[config.defaultProvider]) {
|
|
2534
3870
|
throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
|
|
2535
3871
|
}
|
|
3872
|
+
if (config.varsAdapter) {
|
|
3873
|
+
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
3874
|
+
assertNoReservedVarKeys2(config.varsAdapter);
|
|
3875
|
+
}
|
|
2536
3876
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
2537
3877
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
2538
3878
|
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
@@ -2740,13 +4080,79 @@ var Mailer = class _Mailer {
|
|
|
2740
4080
|
);
|
|
2741
4081
|
}
|
|
2742
4082
|
}
|
|
2743
|
-
async untag(externalId, tag) {
|
|
2744
|
-
const parsed = tagInputSchema.parse({ externalId, tag });
|
|
2745
|
-
if (this.adapter.removeTags) {
|
|
2746
|
-
await this.adapter.removeTags(parsed.externalId, [parsed.tag]);
|
|
2747
|
-
} else {
|
|
2748
|
-
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
4083
|
+
async untag(externalId, tag) {
|
|
4084
|
+
const parsed = tagInputSchema.parse({ externalId, tag });
|
|
4085
|
+
if (this.adapter.removeTags) {
|
|
4086
|
+
await this.adapter.removeTags(parsed.externalId, [parsed.tag]);
|
|
4087
|
+
} else {
|
|
4088
|
+
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
// -------------------------------------------------------------------------
|
|
4092
|
+
// Flow abort
|
|
4093
|
+
// -------------------------------------------------------------------------
|
|
4094
|
+
/**
|
|
4095
|
+
* Abort every active run of one flow for a contact, immediately. Runs parked
|
|
4096
|
+
* in a `wait` exit too — their delayed wake-up jobs find the run exited and
|
|
4097
|
+
* no-op. Also cancels any of the flow's emails still sitting in the send
|
|
4098
|
+
* queue for this contact (queued or awaiting retry), so an abort means no
|
|
4099
|
+
* further mail, not just no further steps.
|
|
4100
|
+
*
|
|
4101
|
+
* No-op (returns zero counts) when nothing is active. Safe to call from the
|
|
4102
|
+
* same handler that processes the business event ("user upgraded").
|
|
4103
|
+
*/
|
|
4104
|
+
async abortFlow(flowSlug, externalId, opts = {}) {
|
|
4105
|
+
const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
|
|
4106
|
+
const flow = await this.collections.flows.findOne(
|
|
4107
|
+
{ slug: parsed.flowSlug },
|
|
4108
|
+
{ projection: { _id: 1 } }
|
|
4109
|
+
);
|
|
4110
|
+
if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
|
|
4111
|
+
const result = await this.abortActiveRuns(
|
|
4112
|
+
{ externalId: parsed.externalId, flowId: flow._id },
|
|
4113
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
4114
|
+
);
|
|
4115
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
4116
|
+
await this.audit({
|
|
4117
|
+
actor: "host",
|
|
4118
|
+
action: "flow.abort",
|
|
4119
|
+
resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
|
|
4120
|
+
diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
4121
|
+
});
|
|
4122
|
+
}
|
|
4123
|
+
return result;
|
|
4124
|
+
}
|
|
4125
|
+
/**
|
|
4126
|
+
* Abort every active flow run for a contact across all flows. Same semantics
|
|
4127
|
+
* as `abortFlow` — for "stop everything" events (account deleted, churned).
|
|
4128
|
+
*/
|
|
4129
|
+
async abortAllFlows(externalId, opts = {}) {
|
|
4130
|
+
const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
|
|
4131
|
+
const result = await this.abortActiveRuns(
|
|
4132
|
+
{ externalId: parsed.externalId },
|
|
4133
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
4134
|
+
);
|
|
4135
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
4136
|
+
await this.audit({
|
|
4137
|
+
actor: "host",
|
|
4138
|
+
action: "flow.abort_all",
|
|
4139
|
+
resource: { collection: "mailer_flow_runs" },
|
|
4140
|
+
diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
4141
|
+
});
|
|
4142
|
+
}
|
|
4143
|
+
return result;
|
|
4144
|
+
}
|
|
4145
|
+
async abortActiveRuns(filter, exitReason) {
|
|
4146
|
+
const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
|
|
4147
|
+
if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
|
|
4148
|
+
for (const run of runs) {
|
|
4149
|
+
await exitFlowRun(run, exitReason, this.runnerContext);
|
|
2749
4150
|
}
|
|
4151
|
+
const cancelled = await this.collections.sends.updateMany(
|
|
4152
|
+
{ flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
|
|
4153
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
4154
|
+
);
|
|
4155
|
+
return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
|
|
2750
4156
|
}
|
|
2751
4157
|
/**
|
|
2752
4158
|
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
@@ -2948,45 +4354,9 @@ var Mailer = class _Mailer {
|
|
|
2948
4354
|
}
|
|
2949
4355
|
};
|
|
2950
4356
|
|
|
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
4357
|
// src/server/index.ts
|
|
2989
4358
|
init_mongo();
|
|
4359
|
+
init_vars();
|
|
2990
4360
|
|
|
2991
4361
|
// src/server/providers/null.ts
|
|
2992
4362
|
var counter = 0;
|
|
@@ -3015,6 +4385,254 @@ var NullProvider = class {
|
|
|
3015
4385
|
// src/server/index.ts
|
|
3016
4386
|
init_sendgrid();
|
|
3017
4387
|
|
|
4388
|
+
// src/server/templates/linter.ts
|
|
4389
|
+
var URL_SHORTENERS = ["bit.ly", "t.co", "tinyurl.com", "goo.gl", "ow.ly"];
|
|
4390
|
+
var SPAM_PHRASES = [
|
|
4391
|
+
{ pattern: /\bFREE\b/, phrase: "FREE" },
|
|
4392
|
+
{ pattern: /\bACT NOW\b/i, phrase: "ACT NOW" },
|
|
4393
|
+
{ pattern: /\b100%\s+guaranteed\b/i, phrase: "100% guaranteed" }
|
|
4394
|
+
];
|
|
4395
|
+
var MARKETING_UNSUBSCRIBE_TAG = /\{\{\s*unsubscribeUrl\s*\}\}/;
|
|
4396
|
+
function lintTemplate(rawInput, config = {}) {
|
|
4397
|
+
const input = {
|
|
4398
|
+
subject: typeof rawInput.subject === "string" ? rawInput.subject : "",
|
|
4399
|
+
preheader: typeof rawInput.preheader === "string" ? rawInput.preheader : "",
|
|
4400
|
+
mjml: typeof rawInput.mjml === "string" ? rawInput.mjml : "",
|
|
4401
|
+
editorJson: rawInput.editorJson,
|
|
4402
|
+
html: typeof rawInput.html === "string" ? rawInput.html : "",
|
|
4403
|
+
plainText: typeof rawInput.plainText === "string" ? rawInput.plainText : "",
|
|
4404
|
+
kind: rawInput.kind === "marketing" || rawInput.kind === "transactional" ? rawInput.kind : "transactional",
|
|
4405
|
+
fromEmail: typeof rawInput.fromEmail === "string" ? rawInput.fromEmail : ""
|
|
4406
|
+
};
|
|
4407
|
+
const issues = [];
|
|
4408
|
+
if (!input.plainText || input.plainText.trim().length === 0) {
|
|
4409
|
+
issues.push({
|
|
4410
|
+
rule: "missing_plain_text",
|
|
4411
|
+
severity: "error",
|
|
4412
|
+
message: "Plain-text alternative is empty.",
|
|
4413
|
+
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."
|
|
4414
|
+
});
|
|
4415
|
+
}
|
|
4416
|
+
const imageCount = countMatches(input.html, /<img\b/gi);
|
|
4417
|
+
const textLen = input.plainText.trim().length;
|
|
4418
|
+
if (imageCount >= 1 && textLen < 20) {
|
|
4419
|
+
issues.push({
|
|
4420
|
+
rule: "image_only_body",
|
|
4421
|
+
severity: "error",
|
|
4422
|
+
message: "Body is mostly images with little or no text.",
|
|
4423
|
+
hint: "Image-only emails are heavily filtered as spam. Add at least one paragraph of meaningful text."
|
|
4424
|
+
});
|
|
4425
|
+
}
|
|
4426
|
+
const shortenerLinks = findShortenerLinks(input.html);
|
|
4427
|
+
if (shortenerLinks.length > 0) {
|
|
4428
|
+
issues.push({
|
|
4429
|
+
rule: "url_shortener",
|
|
4430
|
+
severity: "error",
|
|
4431
|
+
message: `Link uses a URL shortener: ${shortenerLinks.join(", ")}.`,
|
|
4432
|
+
hint: "URL shorteners are strongly correlated with spam and are routinely blocked. Use a direct link to your domain."
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4435
|
+
if (hasBareUrlInVisibleText(input.html)) {
|
|
4436
|
+
issues.push({
|
|
4437
|
+
rule: "bare_url",
|
|
4438
|
+
severity: "warning",
|
|
4439
|
+
message: "A URL appears in the body text without being wrapped in a link.",
|
|
4440
|
+
hint: "Wrap visible URLs in an anchor tag \u2014 bare URLs look like spam template residue and reduce engagement."
|
|
4441
|
+
});
|
|
4442
|
+
}
|
|
4443
|
+
const spam = findSpamSignals(`${input.subject}
|
|
4444
|
+
${input.plainText}`);
|
|
4445
|
+
if (spam.length > 0) {
|
|
4446
|
+
issues.push({
|
|
4447
|
+
rule: "spam_phrases",
|
|
4448
|
+
severity: "warning",
|
|
4449
|
+
message: `Content contains spam-flagged phrases: ${spam.join(", ")}.`,
|
|
4450
|
+
hint: "These phrases trigger content filters. Rephrase if possible."
|
|
4451
|
+
});
|
|
4452
|
+
}
|
|
4453
|
+
if (isAllCaps(input.subject)) {
|
|
4454
|
+
issues.push({
|
|
4455
|
+
rule: "all_caps_subject",
|
|
4456
|
+
severity: "warning",
|
|
4457
|
+
message: "Subject line is mostly uppercase.",
|
|
4458
|
+
hint: "All-caps subjects are flagged by spam filters and reduce click-through. Use sentence case."
|
|
4459
|
+
});
|
|
4460
|
+
}
|
|
4461
|
+
if (input.kind === "marketing") {
|
|
4462
|
+
const sources = [input.mjml ?? "", input.html ?? "", JSON.stringify(input.editorJson ?? null)];
|
|
4463
|
+
const hasTag = sources.some((s) => MARKETING_UNSUBSCRIBE_TAG.test(s));
|
|
4464
|
+
if (!hasTag) {
|
|
4465
|
+
issues.push({
|
|
4466
|
+
rule: "missing_unsubscribe_tag",
|
|
4467
|
+
severity: "error",
|
|
4468
|
+
message: "Marketing template is missing the {{unsubscribeUrl}} merge tag.",
|
|
4469
|
+
hint: "CAN-SPAM, GDPR, and Gmail/Yahoo bulk-sender rules all require a working unsubscribe link. Add {{unsubscribeUrl}} somewhere in the body."
|
|
4470
|
+
});
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
const senderCheck = validateSenderDomain(input.fromEmail, input.kind, config.senderDomains);
|
|
4474
|
+
if (!senderCheck.ok) {
|
|
4475
|
+
issues.push({
|
|
4476
|
+
rule: "sender_domain_invalid",
|
|
4477
|
+
severity: "error",
|
|
4478
|
+
message: senderCheck.reason,
|
|
4479
|
+
hint: "Edit the template fromEmail to use a domain declared for this kind in senderDomains, or update the registry."
|
|
4480
|
+
});
|
|
4481
|
+
}
|
|
4482
|
+
if (!input.preheader || input.preheader.trim().length === 0) {
|
|
4483
|
+
issues.push({
|
|
4484
|
+
rule: "empty_preheader",
|
|
4485
|
+
severity: "info",
|
|
4486
|
+
message: "No preheader set.",
|
|
4487
|
+
hint: "The preheader shows next to the subject in most inbox previews. A good preheader lifts open rate 5-10%."
|
|
4488
|
+
});
|
|
4489
|
+
}
|
|
4490
|
+
if (input.subject.length > 60) {
|
|
4491
|
+
issues.push({
|
|
4492
|
+
rule: "subject_too_long",
|
|
4493
|
+
severity: "warning",
|
|
4494
|
+
message: `Subject is ${input.subject.length} characters; mobile clients truncate around 60.`,
|
|
4495
|
+
hint: "Front-load the important words so the truncated preview still makes sense."
|
|
4496
|
+
});
|
|
4497
|
+
}
|
|
4498
|
+
if (config.varsJsonSchema) {
|
|
4499
|
+
const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
|
|
4500
|
+
const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
|
|
4501
|
+
if (unknown.length > 0) {
|
|
4502
|
+
issues.push({
|
|
4503
|
+
rule: "unknown_variable",
|
|
4504
|
+
severity: "warning",
|
|
4505
|
+
message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
|
|
4506
|
+
hint: "These render as empty strings. Check for typos, or add the key to your varsAdapter schema. Paths inside {{#each}}/{{#with}} blocks are relative and not checked."
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
|
|
4511
|
+
if (linkCount > 10) {
|
|
4512
|
+
issues.push({
|
|
4513
|
+
rule: "too_many_links",
|
|
4514
|
+
severity: "warning",
|
|
4515
|
+
message: `${linkCount} links in body; promotional content with many links is flagged by content filters.`,
|
|
4516
|
+
hint: "Consolidate calls-to-action. Aim for one primary action plus a footer link or two."
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
return splitBySeverity(issues);
|
|
4520
|
+
}
|
|
4521
|
+
function splitBySeverity(issues) {
|
|
4522
|
+
const out = { errors: [], warnings: [], infos: [] };
|
|
4523
|
+
for (const i of issues) {
|
|
4524
|
+
if (i.severity === "error") out.errors.push(i);
|
|
4525
|
+
else if (i.severity === "warning") out.warnings.push(i);
|
|
4526
|
+
else out.infos.push(i);
|
|
4527
|
+
}
|
|
4528
|
+
return out;
|
|
4529
|
+
}
|
|
4530
|
+
function countMatches(s, re) {
|
|
4531
|
+
return (s.match(re) ?? []).length;
|
|
4532
|
+
}
|
|
4533
|
+
function findShortenerLinks(html) {
|
|
4534
|
+
const hrefs = extractHrefs(html);
|
|
4535
|
+
const hits = /* @__PURE__ */ new Set();
|
|
4536
|
+
for (const href of hrefs) {
|
|
4537
|
+
const domain = hostnameOf(href);
|
|
4538
|
+
if (!domain) continue;
|
|
4539
|
+
if (URL_SHORTENERS.includes(domain)) hits.add(domain);
|
|
4540
|
+
}
|
|
4541
|
+
return Array.from(hits);
|
|
4542
|
+
}
|
|
4543
|
+
function extractHrefs(html) {
|
|
4544
|
+
const out = [];
|
|
4545
|
+
const re = /<a\s[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
4546
|
+
let m;
|
|
4547
|
+
while (m = re.exec(html)) {
|
|
4548
|
+
const href = m[1] ?? m[2] ?? m[3];
|
|
4549
|
+
if (href) out.push(href);
|
|
4550
|
+
}
|
|
4551
|
+
return out;
|
|
4552
|
+
}
|
|
4553
|
+
function hostnameOf(url) {
|
|
4554
|
+
try {
|
|
4555
|
+
return new URL(url).hostname.toLowerCase();
|
|
4556
|
+
} catch {
|
|
4557
|
+
return null;
|
|
4558
|
+
}
|
|
4559
|
+
}
|
|
4560
|
+
function hasBareUrlInVisibleText(html) {
|
|
4561
|
+
const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
|
|
4562
|
+
return /https?:\/\/[^\s<>"']+/i.test(stripped);
|
|
4563
|
+
}
|
|
4564
|
+
function findSpamSignals(text) {
|
|
4565
|
+
const out = /* @__PURE__ */ new Set();
|
|
4566
|
+
for (const { pattern, phrase } of SPAM_PHRASES) {
|
|
4567
|
+
if (pattern.test(text)) out.add(phrase);
|
|
4568
|
+
}
|
|
4569
|
+
if (/!{3,}/.test(text)) out.add('excessive "!!!"');
|
|
4570
|
+
return Array.from(out);
|
|
4571
|
+
}
|
|
4572
|
+
var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
|
|
4573
|
+
"contact",
|
|
4574
|
+
"vars",
|
|
4575
|
+
"event",
|
|
4576
|
+
"unsubscribeUrl",
|
|
4577
|
+
"viewInBrowserUrl",
|
|
4578
|
+
"preferenceCenterUrl",
|
|
4579
|
+
"senderAddress",
|
|
4580
|
+
"this"
|
|
4581
|
+
]);
|
|
4582
|
+
var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
|
|
4583
|
+
function findUnknownVariables(source, schema) {
|
|
4584
|
+
const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
|
|
4585
|
+
const unknown = /* @__PURE__ */ new Set();
|
|
4586
|
+
const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
|
|
4587
|
+
let m;
|
|
4588
|
+
while (m = re.exec(source)) {
|
|
4589
|
+
const expr = m[1].trim();
|
|
4590
|
+
if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
|
|
4591
|
+
if (expr.includes("(")) continue;
|
|
4592
|
+
const parts = expr.split(/\s+/);
|
|
4593
|
+
const candidates = parts.length > 1 ? parts.slice(1) : parts;
|
|
4594
|
+
for (const raw of candidates) {
|
|
4595
|
+
if (!PATH_TOKEN.test(raw)) continue;
|
|
4596
|
+
if (raw.startsWith("@")) continue;
|
|
4597
|
+
const segments = raw.split(".");
|
|
4598
|
+
if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
|
|
4599
|
+
if (hasBlockScopes && segments.length === 1) continue;
|
|
4600
|
+
if (!schemaHasPath(schema, segments)) unknown.add(raw);
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
return Array.from(unknown);
|
|
4604
|
+
}
|
|
4605
|
+
function schemaHasPath(node, segments) {
|
|
4606
|
+
if (segments.length === 0) return true;
|
|
4607
|
+
if (!node || typeof node !== "object") return true;
|
|
4608
|
+
const n = node;
|
|
4609
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
4610
|
+
if (Array.isArray(n[key])) {
|
|
4611
|
+
return n[key].some((branch) => schemaHasPath(branch, segments));
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
|
|
4615
|
+
if (n.type === "array") {
|
|
4616
|
+
if (segments[0] === "length") return true;
|
|
4617
|
+
return schemaHasPath(n.items, segments);
|
|
4618
|
+
}
|
|
4619
|
+
const props = n.properties;
|
|
4620
|
+
if (!props || typeof props !== "object") return true;
|
|
4621
|
+
const [head, ...rest] = segments;
|
|
4622
|
+
if (head in props) return schemaHasPath(props[head], rest);
|
|
4623
|
+
if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
|
|
4624
|
+
return true;
|
|
4625
|
+
}
|
|
4626
|
+
function isAllCaps(subject) {
|
|
4627
|
+
const letters = subject.replace(/[^a-zA-Z]/g, "");
|
|
4628
|
+
if (letters.length < 5) return false;
|
|
4629
|
+
const upper = subject.replace(/[^A-Z]/g, "").length;
|
|
4630
|
+
return upper / letters.length > 0.5;
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
// src/server/api/admin.ts
|
|
4634
|
+
init_vars();
|
|
4635
|
+
|
|
3018
4636
|
// src/server/api/setup-status.ts
|
|
3019
4637
|
async function runSetupChecks(mailer) {
|
|
3020
4638
|
const checks = [];
|
|
@@ -3024,6 +4642,12 @@ async function runSetupChecks(mailer) {
|
|
|
3024
4642
|
checks.push(await checkWorkersHeartbeat(mailer));
|
|
3025
4643
|
}
|
|
3026
4644
|
checks.push(await checkCircuitBreaker(mailer));
|
|
4645
|
+
checks.push(await checkDnsbl(mailer));
|
|
4646
|
+
checks.push(await checkPostmaster(mailer));
|
|
4647
|
+
checks.push(await checkSnds(mailer));
|
|
4648
|
+
const jmrp = checkJmrp(mailer);
|
|
4649
|
+
if (jmrp) checks.push(jmrp);
|
|
4650
|
+
checks.push(await checkDmarc(mailer));
|
|
3027
4651
|
checks.push(...checkFromDefaultsAgainstRegistry(mailer));
|
|
3028
4652
|
checks.push(...await checkPublishedTemplates(mailer));
|
|
3029
4653
|
checks.push(await checkPostalAddress(mailer));
|
|
@@ -3073,7 +4697,10 @@ async function checkQueue(mailer) {
|
|
|
3073
4697
|
}
|
|
3074
4698
|
}
|
|
3075
4699
|
async function checkWorkersHeartbeat(mailer) {
|
|
3076
|
-
const h = await mailer.collections.health.findOne(
|
|
4700
|
+
const h = await mailer.collections.health.findOne(
|
|
4701
|
+
{},
|
|
4702
|
+
{ sort: { updatedAt: -1 } }
|
|
4703
|
+
);
|
|
3077
4704
|
const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
|
|
3078
4705
|
const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
|
|
3079
4706
|
if (!h) {
|
|
@@ -3103,25 +4730,264 @@ async function checkWorkersHeartbeat(mailer) {
|
|
|
3103
4730
|
};
|
|
3104
4731
|
}
|
|
3105
4732
|
async function checkCircuitBreaker(mailer) {
|
|
3106
|
-
const
|
|
3107
|
-
|
|
3108
|
-
|
|
4733
|
+
const docs = await mailer.collections.health.find({}).toArray();
|
|
4734
|
+
const buckets = docs.filter((d) => d._id !== HEALTH_AGG_ID);
|
|
4735
|
+
if (buckets.length === 0) {
|
|
4736
|
+
return {
|
|
4737
|
+
name: "circuit_breaker",
|
|
4738
|
+
label: "Circuit breaker",
|
|
4739
|
+
severity: "ok",
|
|
4740
|
+
message: "no telemetry yet",
|
|
4741
|
+
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."
|
|
4742
|
+
};
|
|
4743
|
+
}
|
|
4744
|
+
const tripped = buckets.filter((d) => d.status === "tripped");
|
|
4745
|
+
if (tripped.length > 0) {
|
|
4746
|
+
const summary = tripped.map((d) => `${d.senderDomain ?? "_unknown"}/${d.kind}`).join(", ");
|
|
4747
|
+
return {
|
|
4748
|
+
name: "circuit_breaker",
|
|
4749
|
+
label: "Circuit breaker",
|
|
4750
|
+
severity: "error",
|
|
4751
|
+
message: `tripped: ${summary}`,
|
|
4752
|
+
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)."
|
|
4753
|
+
};
|
|
3109
4754
|
}
|
|
3110
|
-
|
|
4755
|
+
const degraded = buckets.filter((d) => d.status === "degraded");
|
|
4756
|
+
if (degraded.length > 0) {
|
|
4757
|
+
const summary = degraded.map((d) => `${d.senderDomain ?? "_unknown"}/${d.kind}`).join(", ");
|
|
3111
4758
|
return {
|
|
3112
4759
|
name: "circuit_breaker",
|
|
3113
4760
|
label: "Circuit breaker",
|
|
3114
4761
|
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."
|
|
4762
|
+
message: `degraded: ${summary}`,
|
|
4763
|
+
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."
|
|
4764
|
+
};
|
|
4765
|
+
}
|
|
4766
|
+
return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
|
|
4767
|
+
}
|
|
4768
|
+
async function checkDnsbl(mailer) {
|
|
4769
|
+
const docs = await mailer.collections.dnsblChecks.find({}).toArray();
|
|
4770
|
+
if (docs.length === 0) {
|
|
4771
|
+
return {
|
|
4772
|
+
name: "dnsbl",
|
|
4773
|
+
label: "DNS block-list checks",
|
|
4774
|
+
severity: "ok",
|
|
4775
|
+
message: "no checks run yet",
|
|
4776
|
+
hint: "Checks run automatically once per dnsbl.intervalHours (default 24h). To run now, POST /api/dnsbl/recheck."
|
|
4777
|
+
};
|
|
4778
|
+
}
|
|
4779
|
+
const listed = docs.filter((d) => d.result === "listed");
|
|
4780
|
+
if (listed.length > 0) {
|
|
4781
|
+
const summary = listed.map((d) => `${d.target} on ${d.listLabel}`).slice(0, 3).join("; ");
|
|
4782
|
+
const more = listed.length > 3 ? ` (+${listed.length - 3} more)` : "";
|
|
4783
|
+
return {
|
|
4784
|
+
name: "dnsbl",
|
|
4785
|
+
label: "DNS block-list checks",
|
|
4786
|
+
severity: "error",
|
|
4787
|
+
message: `${listed.length} listing(s): ${summary}${more}`,
|
|
4788
|
+
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."
|
|
4789
|
+
};
|
|
4790
|
+
}
|
|
4791
|
+
const errored = docs.filter((d) => d.result === "error");
|
|
4792
|
+
if (errored.length === docs.length) {
|
|
4793
|
+
return {
|
|
4794
|
+
name: "dnsbl",
|
|
4795
|
+
label: "DNS block-list checks",
|
|
4796
|
+
severity: "warn",
|
|
4797
|
+
message: `all ${docs.length} checks errored`,
|
|
4798
|
+
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
4799
|
};
|
|
3118
4800
|
}
|
|
3119
4801
|
return {
|
|
3120
|
-
name: "
|
|
3121
|
-
label: "
|
|
3122
|
-
severity: "
|
|
3123
|
-
message: `
|
|
3124
|
-
|
|
4802
|
+
name: "dnsbl",
|
|
4803
|
+
label: "DNS block-list checks",
|
|
4804
|
+
severity: "ok",
|
|
4805
|
+
message: `clean (${docs.length} checks)`
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
async function checkPostmaster(mailer) {
|
|
4809
|
+
const cfg = mailer.config.postmaster;
|
|
4810
|
+
if (!cfg?.clientId || !cfg?.clientSecret || !cfg?.refreshToken) {
|
|
4811
|
+
return {
|
|
4812
|
+
name: "postmaster",
|
|
4813
|
+
label: "Google Postmaster Tools",
|
|
4814
|
+
severity: "ok",
|
|
4815
|
+
message: "not configured (optional)",
|
|
4816
|
+
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."
|
|
4817
|
+
};
|
|
4818
|
+
}
|
|
4819
|
+
const snapshots = await mailer.collections.postmasterSnapshots.find({}).sort({ fetchedAt: -1 }).toArray();
|
|
4820
|
+
if (snapshots.length === 0) {
|
|
4821
|
+
return {
|
|
4822
|
+
name: "postmaster",
|
|
4823
|
+
label: "Google Postmaster Tools",
|
|
4824
|
+
severity: "warn",
|
|
4825
|
+
message: "configured but no data yet",
|
|
4826
|
+
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."
|
|
4827
|
+
};
|
|
4828
|
+
}
|
|
4829
|
+
const latestByDomain = /* @__PURE__ */ new Map();
|
|
4830
|
+
for (const s of snapshots) {
|
|
4831
|
+
if (!latestByDomain.has(s.domain)) latestByDomain.set(s.domain, s);
|
|
4832
|
+
}
|
|
4833
|
+
const bad = [];
|
|
4834
|
+
const low = [];
|
|
4835
|
+
for (const s of latestByDomain.values()) {
|
|
4836
|
+
if (s.domainReputation === "BAD") bad.push(s.domain);
|
|
4837
|
+
else if (s.domainReputation === "LOW") low.push(s.domain);
|
|
4838
|
+
}
|
|
4839
|
+
if (bad.length > 0) {
|
|
4840
|
+
return {
|
|
4841
|
+
name: "postmaster",
|
|
4842
|
+
label: "Google Postmaster Tools",
|
|
4843
|
+
severity: "error",
|
|
4844
|
+
message: `BAD reputation on ${bad.join(", ")}`,
|
|
4845
|
+
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."
|
|
4846
|
+
};
|
|
4847
|
+
}
|
|
4848
|
+
if (low.length > 0) {
|
|
4849
|
+
return {
|
|
4850
|
+
name: "postmaster",
|
|
4851
|
+
label: "Google Postmaster Tools",
|
|
4852
|
+
severity: "warn",
|
|
4853
|
+
message: `LOW reputation on ${low.join(", ")}`,
|
|
4854
|
+
hint: "Reputation has slipped below Medium. Review recent complaint + bounce activity, slow down marketing volume, and re-engage your most active segment only."
|
|
4855
|
+
};
|
|
4856
|
+
}
|
|
4857
|
+
return {
|
|
4858
|
+
name: "postmaster",
|
|
4859
|
+
label: "Google Postmaster Tools",
|
|
4860
|
+
severity: "ok",
|
|
4861
|
+
// Includes REPUTATION_CATEGORY_UNSPECIFIED ("no data yet") domains —
|
|
4862
|
+
// surface that explicitly so the operator isn't misled.
|
|
4863
|
+
message: (() => {
|
|
4864
|
+
const tiers = new Set(Array.from(latestByDomain.values()).map((d) => d.domainReputation));
|
|
4865
|
+
const hasUnspec = tiers.has("REPUTATION_CATEGORY_UNSPECIFIED");
|
|
4866
|
+
return `${latestByDomain.size} domain(s) tracked${hasUnspec ? " (some have no reputation data yet)" : ", all High/Medium"}`;
|
|
4867
|
+
})()
|
|
4868
|
+
};
|
|
4869
|
+
}
|
|
4870
|
+
function checkJmrp(mailer) {
|
|
4871
|
+
const hasDedicatedIp = (mailer.config.dnsbl?.dedicatedIps?.length ?? 0) > 0;
|
|
4872
|
+
if (!hasDedicatedIp) return null;
|
|
4873
|
+
return {
|
|
4874
|
+
name: "snds_jmrp",
|
|
4875
|
+
label: "Microsoft JMRP enrolment",
|
|
4876
|
+
severity: "ok",
|
|
4877
|
+
message: "manual setup \u2014 confirm in your runbook",
|
|
4878
|
+
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."
|
|
4879
|
+
};
|
|
4880
|
+
}
|
|
4881
|
+
async function checkSnds(mailer) {
|
|
4882
|
+
const cfg = mailer.config.snds;
|
|
4883
|
+
const hasDedicatedIp = (mailer.config.dnsbl?.dedicatedIps?.length ?? 0) > 0;
|
|
4884
|
+
if (!cfg?.accessKey) {
|
|
4885
|
+
if (!hasDedicatedIp) {
|
|
4886
|
+
return {
|
|
4887
|
+
name: "snds",
|
|
4888
|
+
label: "Microsoft SNDS",
|
|
4889
|
+
severity: "ok",
|
|
4890
|
+
message: "not applicable (no dedicated IP)",
|
|
4891
|
+
hint: "SNDS reports IP-level Outlook/Hotmail reputation. Only meaningful when you send from a dedicated IP. Skip unless you do."
|
|
4892
|
+
};
|
|
4893
|
+
}
|
|
4894
|
+
return {
|
|
4895
|
+
name: "snds",
|
|
4896
|
+
label: "Microsoft SNDS",
|
|
4897
|
+
severity: "warn",
|
|
4898
|
+
message: "dedicated IP configured but SNDS access key not set",
|
|
4899
|
+
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."
|
|
4900
|
+
};
|
|
4901
|
+
}
|
|
4902
|
+
const snapshots = await mailer.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).toArray();
|
|
4903
|
+
if (snapshots.length === 0) {
|
|
4904
|
+
return {
|
|
4905
|
+
name: "snds",
|
|
4906
|
+
label: "Microsoft SNDS",
|
|
4907
|
+
severity: "warn",
|
|
4908
|
+
message: "configured but no data yet",
|
|
4909
|
+
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."
|
|
4910
|
+
};
|
|
4911
|
+
}
|
|
4912
|
+
const latestByIp = /* @__PURE__ */ new Map();
|
|
4913
|
+
for (const s of snapshots) {
|
|
4914
|
+
if (!latestByIp.has(s.ip)) latestByIp.set(s.ip, s);
|
|
4915
|
+
}
|
|
4916
|
+
const red = [];
|
|
4917
|
+
const yellow = [];
|
|
4918
|
+
for (const s of latestByIp.values()) {
|
|
4919
|
+
if (s.filterResult === "RED") red.push(s.ip);
|
|
4920
|
+
else if (s.filterResult === "YELLOW") yellow.push(s.ip);
|
|
4921
|
+
}
|
|
4922
|
+
if (red.length > 0) {
|
|
4923
|
+
return {
|
|
4924
|
+
name: "snds",
|
|
4925
|
+
label: "Microsoft SNDS",
|
|
4926
|
+
severity: "error",
|
|
4927
|
+
message: `RED filter on ${red.join(", ")}`,
|
|
4928
|
+
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)."
|
|
4929
|
+
};
|
|
4930
|
+
}
|
|
4931
|
+
if (yellow.length > 0) {
|
|
4932
|
+
return {
|
|
4933
|
+
name: "snds",
|
|
4934
|
+
label: "Microsoft SNDS",
|
|
4935
|
+
severity: "warn",
|
|
4936
|
+
message: `YELLOW filter on ${yellow.join(", ")}`,
|
|
4937
|
+
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."
|
|
4938
|
+
};
|
|
4939
|
+
}
|
|
4940
|
+
return {
|
|
4941
|
+
name: "snds",
|
|
4942
|
+
label: "Microsoft SNDS",
|
|
4943
|
+
severity: "ok",
|
|
4944
|
+
message: `${latestByIp.size} IP(s) tracked, all GREEN`
|
|
4945
|
+
};
|
|
4946
|
+
}
|
|
4947
|
+
async function checkDmarc(mailer) {
|
|
4948
|
+
const reportCount = await mailer.collections.dmarcReports.estimatedDocumentCount();
|
|
4949
|
+
if (reportCount === 0) {
|
|
4950
|
+
return {
|
|
4951
|
+
name: "dmarc",
|
|
4952
|
+
label: "DMARC RUA reports",
|
|
4953
|
+
severity: "ok",
|
|
4954
|
+
message: "no reports ingested yet",
|
|
4955
|
+
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."
|
|
4956
|
+
};
|
|
4957
|
+
}
|
|
4958
|
+
const since = new Date(Date.now() - 7 * 864e5);
|
|
4959
|
+
const recent = await mailer.collections.dmarcReports.find({ rangeEnd: { $gte: since } }).toArray();
|
|
4960
|
+
if (recent.length === 0) {
|
|
4961
|
+
return {
|
|
4962
|
+
name: "dmarc",
|
|
4963
|
+
label: "DMARC RUA reports",
|
|
4964
|
+
severity: "warn",
|
|
4965
|
+
message: `no reports in the last 7 days (${reportCount} total)`,
|
|
4966
|
+
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."
|
|
4967
|
+
};
|
|
4968
|
+
}
|
|
4969
|
+
const tagged = await resolveSourceTags(mailer.getRunnerContext());
|
|
4970
|
+
const known = new Set(Array.from(tagged.keys()));
|
|
4971
|
+
const since30 = new Date(Date.now() - 30 * 864e5);
|
|
4972
|
+
const failures = await mailer.collections.dmarcFailures.find({ receivedAt: { $gte: since30 } }).toArray();
|
|
4973
|
+
const unknownFailing = failures.filter((f) => !known.has(f.sourceIp));
|
|
4974
|
+
if (unknownFailing.length === 0) {
|
|
4975
|
+
return {
|
|
4976
|
+
name: "dmarc",
|
|
4977
|
+
label: "DMARC RUA reports",
|
|
4978
|
+
severity: "ok",
|
|
4979
|
+
message: `${recent.length} report(s) this week, no failing unknown sources`
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
const totalFailing = unknownFailing.reduce((acc, f) => acc + f.count, 0);
|
|
4983
|
+
const ips = Array.from(new Set(unknownFailing.map((f) => f.sourceIp))).slice(0, 3);
|
|
4984
|
+
const more = unknownFailing.length > 3 ? ` (+${unknownFailing.length - 3} more)` : "";
|
|
4985
|
+
return {
|
|
4986
|
+
name: "dmarc",
|
|
4987
|
+
label: "DMARC RUA reports",
|
|
4988
|
+
severity: "warn",
|
|
4989
|
+
message: `${totalFailing} failing message(s) from ${ips.join(", ")}${more}`,
|
|
4990
|
+
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
4991
|
};
|
|
3126
4992
|
}
|
|
3127
4993
|
function checkFromDefaultsAgainstRegistry(mailer) {
|
|
@@ -3220,18 +5086,298 @@ async function checkDoiTemplate(mailer) {
|
|
|
3220
5086
|
return { name: "doi_template", label: "DOI template", severity: "ok", message: `template "${tpl.slug}" published` };
|
|
3221
5087
|
}
|
|
3222
5088
|
return {
|
|
3223
|
-
name: "doi_template",
|
|
3224
|
-
label: "DOI template",
|
|
3225
|
-
severity: "error",
|
|
3226
|
-
message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
|
|
3227
|
-
hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
|
|
5089
|
+
name: "doi_template",
|
|
5090
|
+
label: "DOI template",
|
|
5091
|
+
severity: "error",
|
|
5092
|
+
message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
|
|
5093
|
+
hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
|
|
5094
|
+
};
|
|
5095
|
+
}
|
|
5096
|
+
function humanDuration(ms) {
|
|
5097
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
5098
|
+
if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
|
|
5099
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
|
|
5100
|
+
return `${Math.round(ms / 36e5)}h`;
|
|
5101
|
+
}
|
|
5102
|
+
|
|
5103
|
+
// src/server/runner/hygiene.ts
|
|
5104
|
+
var DAY_MS = 864e5;
|
|
5105
|
+
var HYGIENE_BUCKETS = [
|
|
5106
|
+
{ label: "Engaged (last 30 days)", maxDays: 30 },
|
|
5107
|
+
{ label: "Engaged (31-60 days)", minDays: 30, maxDays: 60 },
|
|
5108
|
+
{ label: "Engaged (61-90 days)", minDays: 60, maxDays: 90 },
|
|
5109
|
+
{ label: "Engaged (91-180 days)", minDays: 90, maxDays: 180 },
|
|
5110
|
+
{ label: "Inactive (>180 days)", minDays: 180 },
|
|
5111
|
+
{ label: "Never engaged", neverEngaged: true }
|
|
5112
|
+
];
|
|
5113
|
+
async function computeListHygiene(ctx, opts = {}) {
|
|
5114
|
+
const now = opts.now ?? Date.now();
|
|
5115
|
+
const sunsetDays = opts.sunsetThresholdDays ?? 180;
|
|
5116
|
+
const recentDays = opts.recentWindowDays ?? 90;
|
|
5117
|
+
const recentCutoff = new Date(now - recentDays * DAY_MS);
|
|
5118
|
+
const totalSubscribers = await ctx.collections.subscriptions.countDocuments({ status: "subscribed" });
|
|
5119
|
+
const subscribedIds = /* @__PURE__ */ new Set();
|
|
5120
|
+
{
|
|
5121
|
+
const cursor2 = ctx.collections.subscriptions.find({ status: "subscribed" }, { projection: { externalId: 1 } });
|
|
5122
|
+
for await (const s of cursor2) subscribedIds.add(s.externalId);
|
|
5123
|
+
}
|
|
5124
|
+
const cursor = ctx.collections.sends.aggregate(
|
|
5125
|
+
[
|
|
5126
|
+
{
|
|
5127
|
+
$group: {
|
|
5128
|
+
_id: "$externalId",
|
|
5129
|
+
lastOpened: { $max: "$openedAt" },
|
|
5130
|
+
lastClicked: { $max: "$firstClickAt" },
|
|
5131
|
+
firstSent: { $min: "$queuedAt" },
|
|
5132
|
+
totalSends: { $sum: 1 },
|
|
5133
|
+
bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
|
|
5134
|
+
hardBounced: {
|
|
5135
|
+
$sum: {
|
|
5136
|
+
$cond: [
|
|
5137
|
+
{ $and: [{ $eq: ["$status", "bounced"] }, { $eq: ["$bounceType", "hard"] }] },
|
|
5138
|
+
1,
|
|
5139
|
+
0
|
|
5140
|
+
]
|
|
5141
|
+
}
|
|
5142
|
+
},
|
|
5143
|
+
complained: { $sum: { $cond: [{ $ne: ["$complainedAt", null] }, 1, 0] } },
|
|
5144
|
+
recentSends: { $sum: { $cond: [{ $gte: ["$queuedAt", recentCutoff] }, 1, 0] } },
|
|
5145
|
+
recentBounced: {
|
|
5146
|
+
$sum: {
|
|
5147
|
+
$cond: [
|
|
5148
|
+
{ $and: [{ $eq: ["$status", "bounced"] }, { $gte: ["$queuedAt", recentCutoff] }] },
|
|
5149
|
+
1,
|
|
5150
|
+
0
|
|
5151
|
+
]
|
|
5152
|
+
}
|
|
5153
|
+
},
|
|
5154
|
+
recentComplained: {
|
|
5155
|
+
$sum: {
|
|
5156
|
+
$cond: [
|
|
5157
|
+
{ $and: [{ $ne: ["$complainedAt", null] }, { $gte: ["$queuedAt", recentCutoff] }] },
|
|
5158
|
+
1,
|
|
5159
|
+
0
|
|
5160
|
+
]
|
|
5161
|
+
}
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
5165
|
+
],
|
|
5166
|
+
{ allowDiskUse: true }
|
|
5167
|
+
);
|
|
5168
|
+
const buckets = HYGIENE_BUCKETS.map((b) => ({
|
|
5169
|
+
label: b.label,
|
|
5170
|
+
minDays: "minDays" in b ? b.minDays : null,
|
|
5171
|
+
maxDays: "maxDays" in b ? b.maxDays : null,
|
|
5172
|
+
neverEngaged: "neverEngaged" in b && b.neverEngaged,
|
|
5173
|
+
count: 0,
|
|
5174
|
+
pctOfTotal: 0
|
|
5175
|
+
}));
|
|
5176
|
+
let totalWithSends = 0;
|
|
5177
|
+
let sunsetCount = 0;
|
|
5178
|
+
const cohortLifetimeAcc = { totalSends: 0, bounced: 0, hardBounced: 0, complained: 0 };
|
|
5179
|
+
const cohortRecentAcc = { totalSends: 0, bounced: 0, complained: 0 };
|
|
5180
|
+
for await (const row of cursor) {
|
|
5181
|
+
if (!subscribedIds.has(row._id)) continue;
|
|
5182
|
+
totalWithSends++;
|
|
5183
|
+
const lastOpened = row.lastOpened ? new Date(row.lastOpened).getTime() : null;
|
|
5184
|
+
const lastClicked = row.lastClicked ? new Date(row.lastClicked).getTime() : null;
|
|
5185
|
+
const lastEngaged = lastOpened == null && lastClicked == null ? null : Math.max(lastOpened ?? 0, lastClicked ?? 0);
|
|
5186
|
+
let isSunset = false;
|
|
5187
|
+
if (lastEngaged == null) {
|
|
5188
|
+
const firstSent = row.firstSent ? new Date(row.firstSent).getTime() : null;
|
|
5189
|
+
buckets[5].count++;
|
|
5190
|
+
if (firstSent != null && now - firstSent > sunsetDays * DAY_MS) {
|
|
5191
|
+
isSunset = true;
|
|
5192
|
+
}
|
|
5193
|
+
} else {
|
|
5194
|
+
const daysSince = (now - lastEngaged) / DAY_MS;
|
|
5195
|
+
if (daysSince <= 30) buckets[0].count++;
|
|
5196
|
+
else if (daysSince <= 60) buckets[1].count++;
|
|
5197
|
+
else if (daysSince <= 90) buckets[2].count++;
|
|
5198
|
+
else if (daysSince <= 180) buckets[3].count++;
|
|
5199
|
+
else {
|
|
5200
|
+
buckets[4].count++;
|
|
5201
|
+
isSunset = true;
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
if (isSunset) {
|
|
5205
|
+
sunsetCount++;
|
|
5206
|
+
cohortLifetimeAcc.totalSends += row.totalSends;
|
|
5207
|
+
cohortLifetimeAcc.bounced += row.bounced;
|
|
5208
|
+
cohortLifetimeAcc.hardBounced += row.hardBounced;
|
|
5209
|
+
cohortLifetimeAcc.complained += row.complained;
|
|
5210
|
+
cohortRecentAcc.totalSends += row.recentSends;
|
|
5211
|
+
cohortRecentAcc.bounced += row.recentBounced;
|
|
5212
|
+
cohortRecentAcc.complained += row.recentComplained;
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
for (const b of buckets) {
|
|
5216
|
+
b.pctOfTotal = totalSubscribers === 0 ? 0 : b.count / totalSubscribers;
|
|
5217
|
+
}
|
|
5218
|
+
const cohortLifetime = cohortLifetimeAcc.totalSends > 0 ? {
|
|
5219
|
+
totalSends: cohortLifetimeAcc.totalSends,
|
|
5220
|
+
bouncedRate: cohortLifetimeAcc.bounced / cohortLifetimeAcc.totalSends,
|
|
5221
|
+
hardBouncedRate: cohortLifetimeAcc.hardBounced / cohortLifetimeAcc.totalSends,
|
|
5222
|
+
complaintRate: cohortLifetimeAcc.complained / cohortLifetimeAcc.totalSends
|
|
5223
|
+
} : null;
|
|
5224
|
+
let projectedImpact = null;
|
|
5225
|
+
const recentAll = await ctx.collections.sends.aggregate([
|
|
5226
|
+
{ $match: { queuedAt: { $gte: recentCutoff } } },
|
|
5227
|
+
{
|
|
5228
|
+
$group: {
|
|
5229
|
+
_id: null,
|
|
5230
|
+
totalSends: { $sum: 1 },
|
|
5231
|
+
bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
|
|
5232
|
+
complained: { $sum: { $cond: [{ $ne: ["$complainedAt", null] }, 1, 0] } }
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
]).toArray();
|
|
5236
|
+
if (recentAll[0] && recentAll[0].totalSends > 0) {
|
|
5237
|
+
const all = recentAll[0];
|
|
5238
|
+
const cohort = cohortRecentAcc;
|
|
5239
|
+
const currentBounceRate = all.bounced / all.totalSends;
|
|
5240
|
+
const currentComplaintRate = all.complained / all.totalSends;
|
|
5241
|
+
const remainingSends = all.totalSends - cohort.totalSends;
|
|
5242
|
+
const remainingBounces = all.bounced - cohort.bounced;
|
|
5243
|
+
const remainingComplaints = all.complained - cohort.complained;
|
|
5244
|
+
const projectedBounceRate = remainingSends > 0 ? remainingBounces / remainingSends : 0;
|
|
5245
|
+
const projectedComplaintRate = remainingSends > 0 ? remainingComplaints / remainingSends : 0;
|
|
5246
|
+
projectedImpact = {
|
|
5247
|
+
recentTotalSends: all.totalSends,
|
|
5248
|
+
recentBounced: all.bounced,
|
|
5249
|
+
recentComplained: all.complained,
|
|
5250
|
+
cohortRecentSends: cohort.totalSends,
|
|
5251
|
+
cohortRecentBounced: cohort.bounced,
|
|
5252
|
+
cohortRecentComplained: cohort.complained,
|
|
5253
|
+
currentOverallBounceRate: currentBounceRate,
|
|
5254
|
+
projectedBounceRate,
|
|
5255
|
+
currentOverallComplaintRate: currentComplaintRate,
|
|
5256
|
+
projectedComplaintRate
|
|
5257
|
+
};
|
|
5258
|
+
}
|
|
5259
|
+
return {
|
|
5260
|
+
totalSubscribers,
|
|
5261
|
+
totalWithSends,
|
|
5262
|
+
buckets,
|
|
5263
|
+
sunsetCandidate: {
|
|
5264
|
+
count: sunsetCount,
|
|
5265
|
+
pctOfTotal: totalSubscribers === 0 ? 0 : sunsetCount / totalSubscribers,
|
|
5266
|
+
cohortLifetime,
|
|
5267
|
+
projectedImpact
|
|
5268
|
+
},
|
|
5269
|
+
computedAt: new Date(now).toISOString()
|
|
5270
|
+
};
|
|
5271
|
+
}
|
|
5272
|
+
var FETCH_TIMEOUT_MS3 = 15e3;
|
|
5273
|
+
function createMailTesterClient(cfg, fetcher = globalThis.fetch) {
|
|
5274
|
+
const base = cfg.baseUrl ?? "https://mail-tester.com/api";
|
|
5275
|
+
function sanitize(s) {
|
|
5276
|
+
if (!cfg.apiKey) return s;
|
|
5277
|
+
return s.split(cfg.apiKey).join("<redacted>").split(encodeURIComponent(cfg.apiKey)).join("<redacted>");
|
|
5278
|
+
}
|
|
5279
|
+
async function call(method, path3, body) {
|
|
5280
|
+
const ctl = new AbortController();
|
|
5281
|
+
const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS3);
|
|
5282
|
+
let res;
|
|
5283
|
+
try {
|
|
5284
|
+
res = await fetcher(`${base}/${encodeURIComponent(cfg.apiKey)}${path3}`, {
|
|
5285
|
+
method,
|
|
5286
|
+
headers: { "content-type": "application/json" },
|
|
5287
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
5288
|
+
signal: ctl.signal
|
|
5289
|
+
});
|
|
5290
|
+
} catch (err) {
|
|
5291
|
+
throw new Error(`Mail-Tester ${method} ${sanitize(path3)} failed: ${sanitize(String(err?.message ?? err))}`);
|
|
5292
|
+
} finally {
|
|
5293
|
+
clearTimeout(t);
|
|
5294
|
+
}
|
|
5295
|
+
if (!res.ok) {
|
|
5296
|
+
const text = await safeText3(res);
|
|
5297
|
+
throw new Error(`Mail-Tester ${method} ${sanitize(path3)} \u2192 ${res.status}: ${sanitize(text)}`);
|
|
5298
|
+
}
|
|
5299
|
+
return res.json();
|
|
5300
|
+
}
|
|
5301
|
+
return {
|
|
5302
|
+
async provisionCheck() {
|
|
5303
|
+
const data = await call("POST", "/new");
|
|
5304
|
+
const checkId = String(data.check_id ?? data.checkId ?? "");
|
|
5305
|
+
const emailAddress = String(data.email_address ?? data.email ?? "");
|
|
5306
|
+
if (!checkId || !emailAddress) throw new Error("Mail-Tester /new returned an unexpected shape");
|
|
5307
|
+
return { checkId, emailAddress };
|
|
5308
|
+
},
|
|
5309
|
+
async fetchResult(checkId) {
|
|
5310
|
+
const data = await call("GET", `/${encodeURIComponent(checkId)}`);
|
|
5311
|
+
const ready = Boolean(data.ready ?? data.complete ?? data.score != null);
|
|
5312
|
+
const scoreRaw = Number(data.score);
|
|
5313
|
+
const score = Number.isFinite(scoreRaw) ? scoreRaw : 0;
|
|
5314
|
+
const feedback = Array.isArray(data.feedback) ? data.feedback.map(normalizeFeedback) : [];
|
|
5315
|
+
const rawSummary = typeof data.summary === "string" ? data.summary : null;
|
|
5316
|
+
return { ready: ready && Number.isFinite(scoreRaw), score, feedback, rawSummary };
|
|
5317
|
+
}
|
|
5318
|
+
};
|
|
5319
|
+
}
|
|
5320
|
+
function normalizeFeedback(raw) {
|
|
5321
|
+
const severity = String(raw.severity ?? "info").toLowerCase();
|
|
5322
|
+
return {
|
|
5323
|
+
category: String(raw.category ?? "other"),
|
|
5324
|
+
severity: severity === "error" || severity === "warning" || severity === "info" ? severity : "info",
|
|
5325
|
+
message: String(raw.message ?? "")
|
|
3228
5326
|
};
|
|
3229
5327
|
}
|
|
3230
|
-
function
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
5328
|
+
async function safeText3(res) {
|
|
5329
|
+
try {
|
|
5330
|
+
return await res.text();
|
|
5331
|
+
} catch {
|
|
5332
|
+
return "<no body>";
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
function mailTesterContentKey(input) {
|
|
5336
|
+
return crypto2__default.default.createHash("sha256").update(`${input.bodyHash}|${input.subject}|${input.fromEmail}`).digest("hex");
|
|
5337
|
+
}
|
|
5338
|
+
async function findCachedScore(ctx, contentKey) {
|
|
5339
|
+
const doc = await ctx.collections.mailTesterScores.findOne({ contentKey });
|
|
5340
|
+
if (!doc) return null;
|
|
5341
|
+
if (new Date(doc.expiresAt).getTime() < Date.now()) return null;
|
|
5342
|
+
return doc;
|
|
5343
|
+
}
|
|
5344
|
+
async function persistScore(ctx, input) {
|
|
5345
|
+
const cfg = ctx.config.mailTester;
|
|
5346
|
+
const hours = cfg?.cacheHours ?? 24;
|
|
5347
|
+
const now = /* @__PURE__ */ new Date();
|
|
5348
|
+
const expiresAt = new Date(now.getTime() + hours * 60 * 60 * 1e3);
|
|
5349
|
+
const doc = {
|
|
5350
|
+
templateSlug: input.templateSlug,
|
|
5351
|
+
contentKey: input.contentKey,
|
|
5352
|
+
checkId: input.checkId,
|
|
5353
|
+
score: input.result.score,
|
|
5354
|
+
feedback: input.result.feedback,
|
|
5355
|
+
rawSummary: input.result.rawSummary,
|
|
5356
|
+
fetchedAt: now,
|
|
5357
|
+
expiresAt
|
|
5358
|
+
};
|
|
5359
|
+
await ctx.collections.mailTesterScores.updateOne(
|
|
5360
|
+
{ contentKey: input.contentKey },
|
|
5361
|
+
{ $set: doc },
|
|
5362
|
+
{ upsert: true }
|
|
5363
|
+
);
|
|
5364
|
+
return doc;
|
|
5365
|
+
}
|
|
5366
|
+
async function evaluateMailTesterGate(ctx, input) {
|
|
5367
|
+
const cfg = ctx.config.mailTester;
|
|
5368
|
+
if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
|
|
5369
|
+
const minScore = cfg.minScore ?? 8;
|
|
5370
|
+
const key = mailTesterContentKey(input);
|
|
5371
|
+
const cached = await findCachedScore(ctx, key);
|
|
5372
|
+
if (!cached) return { allowed: true, reason: null, score: null };
|
|
5373
|
+
if (cached.score < minScore) {
|
|
5374
|
+
return {
|
|
5375
|
+
allowed: false,
|
|
5376
|
+
reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
|
|
5377
|
+
score: cached
|
|
5378
|
+
};
|
|
5379
|
+
}
|
|
5380
|
+
return { allowed: true, reason: null, score: cached };
|
|
3235
5381
|
}
|
|
3236
5382
|
|
|
3237
5383
|
// src/server/api/admin.ts
|
|
@@ -3257,30 +5403,43 @@ function createAdminRouter(mailer, opts = {}) {
|
|
|
3257
5403
|
req.actor = getActor(req);
|
|
3258
5404
|
next();
|
|
3259
5405
|
});
|
|
3260
|
-
router.use("/api", apiRouter(mailer));
|
|
5406
|
+
router.use("/api", apiRouter(mailer, opts));
|
|
3261
5407
|
router.get(/.*/, (_req, res) => {
|
|
3262
5408
|
res.sendFile(path__default.default.join(spaDir, "index.html"));
|
|
3263
5409
|
});
|
|
3264
5410
|
return router;
|
|
3265
5411
|
}
|
|
3266
|
-
function apiRouter(mailer) {
|
|
5412
|
+
function apiRouter(mailer, opts = {}) {
|
|
3267
5413
|
const r = express.Router();
|
|
3268
5414
|
const c = mailer.collections;
|
|
5415
|
+
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
5416
|
+
function getMailTesterClient() {
|
|
5417
|
+
if (opts.mailTesterClient) return opts.mailTesterClient;
|
|
5418
|
+
const cfg = mailer.config.mailTester;
|
|
5419
|
+
if (!cfg?.apiKey) return null;
|
|
5420
|
+
return createMailTesterClient(cfg);
|
|
5421
|
+
}
|
|
5422
|
+
r.get(
|
|
5423
|
+
"/vars-schema",
|
|
5424
|
+
asyncHandler(async (_req, res) => {
|
|
5425
|
+
res.json({ schema: varsSchema, builtins: exports.RESERVED_VAR_KEYS });
|
|
5426
|
+
})
|
|
5427
|
+
);
|
|
3269
5428
|
r.get(
|
|
3270
5429
|
"/me",
|
|
3271
5430
|
asyncHandler(async (req, res) => {
|
|
3272
|
-
const [flows, templates, broadcasts, contacts, suppressions,
|
|
5431
|
+
const [flows, templates, broadcasts, contacts, suppressions, healthDocs] = await Promise.all([
|
|
3273
5432
|
c.flows.estimatedDocumentCount(),
|
|
3274
5433
|
c.templates.estimatedDocumentCount(),
|
|
3275
5434
|
c.broadcasts.estimatedDocumentCount(),
|
|
3276
5435
|
c.subscriptions.countDocuments({ status: "subscribed" }),
|
|
3277
5436
|
c.suppressions.estimatedDocumentCount(),
|
|
3278
|
-
c.health.
|
|
5437
|
+
c.health.find({}).limit(500).toArray()
|
|
3279
5438
|
]);
|
|
3280
5439
|
res.json({
|
|
3281
5440
|
actor: req.actor,
|
|
3282
5441
|
counts: { flows, templates, broadcasts, contacts, suppressions },
|
|
3283
|
-
health: { status:
|
|
5442
|
+
health: { status: effectiveOverallStatus(healthDocs) },
|
|
3284
5443
|
providers: {
|
|
3285
5444
|
names: Object.keys(mailer.config.providers),
|
|
3286
5445
|
default: mailer.config.defaultProvider
|
|
@@ -3347,7 +5506,8 @@ function apiRouter(mailer) {
|
|
|
3347
5506
|
if (prevD === 0 || curD === 0) return null;
|
|
3348
5507
|
return curN / curD - prevN / prevD;
|
|
3349
5508
|
};
|
|
3350
|
-
const
|
|
5509
|
+
const healthDocs = await c.health.find({}).limit(500).toArray();
|
|
5510
|
+
const healthAgg = healthDocs.find((d) => d._id === HEALTH_AGG_ID) ?? null;
|
|
3351
5511
|
const recentFlowsRaw = await c.flows.find({ enabled: true }).limit(5).toArray();
|
|
3352
5512
|
const flowStatsMap = await computeFlowStats(mailer);
|
|
3353
5513
|
const recentFlows = recentFlowsRaw.map((f) => ({ ...f, stats: flowStatsMap.get(f.slug) ?? emptyFlowStats() }));
|
|
@@ -3376,8 +5536,8 @@ function apiRouter(mailer) {
|
|
|
3376
5536
|
},
|
|
3377
5537
|
series: { hourly: { sends: sendSeries, opens: openSeries } },
|
|
3378
5538
|
health: {
|
|
3379
|
-
status:
|
|
3380
|
-
rates:
|
|
5539
|
+
status: effectiveOverallStatus(healthDocs),
|
|
5540
|
+
rates: healthAgg?.rates ?? null,
|
|
3381
5541
|
thresholds: {
|
|
3382
5542
|
hardBounceRatePctTrip: mailer.config.circuitBreaker.hardBounceRatePctTrip,
|
|
3383
5543
|
complaintRatePctTrip: mailer.config.circuitBreaker.complaintRatePctTrip,
|
|
@@ -3397,6 +5557,19 @@ function apiRouter(mailer) {
|
|
|
3397
5557
|
});
|
|
3398
5558
|
})
|
|
3399
5559
|
);
|
|
5560
|
+
r.get(
|
|
5561
|
+
"/events",
|
|
5562
|
+
asyncHandler(async (_req, res) => {
|
|
5563
|
+
const registered = mailer.events.list();
|
|
5564
|
+
const seenNames = await c.events.distinct("name");
|
|
5565
|
+
const known = new Set(registered.map((r2) => r2.name));
|
|
5566
|
+
const unregistered = seenNames.filter((n) => n && !known.has(n));
|
|
5567
|
+
res.json({
|
|
5568
|
+
registered: registered.sort((a, b) => a.name.localeCompare(b.name)),
|
|
5569
|
+
seen: unregistered.sort()
|
|
5570
|
+
});
|
|
5571
|
+
})
|
|
5572
|
+
);
|
|
3400
5573
|
r.get(
|
|
3401
5574
|
"/flows",
|
|
3402
5575
|
asyncHandler(async (_req, res) => {
|
|
@@ -3506,7 +5679,7 @@ function apiRouter(mailer) {
|
|
|
3506
5679
|
c.subscriptions.findOne({ externalId }),
|
|
3507
5680
|
c.events.find({ externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
|
|
3508
5681
|
c.sends.find({ externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
|
|
3509
|
-
c.flowRuns.find({ externalId, status: "active" }).toArray()
|
|
5682
|
+
c.flowRuns.find({ externalId, status: "active" }).sort({ nextActionAt: 1 }).limit(50).toArray()
|
|
3510
5683
|
]);
|
|
3511
5684
|
if (!contact) return res.status(404).json({ error: "not_found" });
|
|
3512
5685
|
return res.json({ contact, subscription, recentEvents, recentSends, activeRuns });
|
|
@@ -3530,7 +5703,7 @@ function apiRouter(mailer) {
|
|
|
3530
5703
|
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
3531
5704
|
const send = await c.sends.findOne({ _id: new mongodb.ObjectId(id) });
|
|
3532
5705
|
if (!send) return res.status(404).json({ error: "not_found" });
|
|
3533
|
-
const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).toArray() : [];
|
|
5706
|
+
const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).sort({ receivedAt: -1 }).limit(100).toArray() : [];
|
|
3534
5707
|
return res.json({ send, webhookEvents: events });
|
|
3535
5708
|
})
|
|
3536
5709
|
);
|
|
@@ -3572,7 +5745,6 @@ function apiRouter(mailer) {
|
|
|
3572
5745
|
r.get(
|
|
3573
5746
|
"/health",
|
|
3574
5747
|
asyncHandler(async (_req, res) => {
|
|
3575
|
-
const h = await c.health.findOne({ _id: "singleton" });
|
|
3576
5748
|
const cb = mailer.config.circuitBreaker;
|
|
3577
5749
|
const thresholds = {
|
|
3578
5750
|
hardBounceRatePctTrip: cb.hardBounceRatePctTrip,
|
|
@@ -3580,11 +5752,22 @@ function apiRouter(mailer) {
|
|
|
3580
5752
|
combinedBounceRatePctTrip: cb.combinedBounceRatePctTrip,
|
|
3581
5753
|
failedToSendRatePctDegrade: cb.failedToSendRatePctDegrade
|
|
3582
5754
|
};
|
|
3583
|
-
|
|
3584
|
-
|
|
5755
|
+
const docs = await c.health.find({}).limit(500).toArray();
|
|
5756
|
+
const aggregate = docs.find((d) => d._id === HEALTH_AGG_ID) ?? null;
|
|
5757
|
+
const buckets = docs.filter((d) => d._id !== HEALTH_AGG_ID);
|
|
5758
|
+
const overall = effectiveOverallStatus(docs);
|
|
5759
|
+
if (docs.length === 0) {
|
|
5760
|
+
res.json({ status: null, rates: null, counters: null, aggregate: null, buckets: [], thresholds });
|
|
3585
5761
|
return;
|
|
3586
5762
|
}
|
|
3587
|
-
res.json({
|
|
5763
|
+
res.json({
|
|
5764
|
+
status: overall,
|
|
5765
|
+
rates: aggregate?.rates ?? null,
|
|
5766
|
+
counters: aggregate?.counters ?? null,
|
|
5767
|
+
aggregate,
|
|
5768
|
+
buckets,
|
|
5769
|
+
thresholds
|
|
5770
|
+
});
|
|
3588
5771
|
})
|
|
3589
5772
|
);
|
|
3590
5773
|
r.get(
|
|
@@ -3597,18 +5780,330 @@ function apiRouter(mailer) {
|
|
|
3597
5780
|
r.post(
|
|
3598
5781
|
"/health/resume",
|
|
3599
5782
|
asyncHandler(async (req, res) => {
|
|
3600
|
-
|
|
3601
|
-
|
|
5783
|
+
const body = req.body ?? {};
|
|
5784
|
+
if (body.senderDomain != null !== (body.kind != null)) {
|
|
5785
|
+
return res.status(400).json({
|
|
5786
|
+
error: "validation_failed",
|
|
5787
|
+
message: "senderDomain and kind must both be provided to target a single bucket, or both omitted to resume all tripped buckets."
|
|
5788
|
+
});
|
|
5789
|
+
}
|
|
5790
|
+
if (body.kind && !["marketing", "transactional"].includes(body.kind)) {
|
|
5791
|
+
return res.status(400).json({ error: "validation_failed", message: 'kind must be "marketing" or "transactional"' });
|
|
5792
|
+
}
|
|
5793
|
+
const target = body.senderDomain && body.kind ? { _id: healthBucketId(body.senderDomain.toLowerCase(), body.kind) } : { status: "tripped", _id: { $ne: HEALTH_AGG_ID } };
|
|
5794
|
+
const result = await c.health.updateMany(
|
|
5795
|
+
target,
|
|
3602
5796
|
{ $set: { status: "healthy", manuallyResumedAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() } }
|
|
3603
5797
|
);
|
|
3604
5798
|
await mailer.audit({
|
|
3605
5799
|
actor: req.actor,
|
|
3606
5800
|
action: "health.resume",
|
|
3607
|
-
resource: {
|
|
5801
|
+
resource: {
|
|
5802
|
+
collection: "mailer_health",
|
|
5803
|
+
id: body.senderDomain && body.kind ? `${body.senderDomain}|${body.kind}` : "all-tripped"
|
|
5804
|
+
}
|
|
5805
|
+
});
|
|
5806
|
+
res.json({ ok: true, resumed: result.modifiedCount });
|
|
5807
|
+
})
|
|
5808
|
+
);
|
|
5809
|
+
r.get(
|
|
5810
|
+
"/dnsbl",
|
|
5811
|
+
asyncHandler(async (_req, res) => {
|
|
5812
|
+
const checks = await c.dnsblChecks.find({}).sort({ result: 1, target: 1, list: 1 }).limit(500).toArray();
|
|
5813
|
+
const latestRunAt = checks.reduce((acc, d) => {
|
|
5814
|
+
const t = new Date(d.runAt);
|
|
5815
|
+
return acc && acc.getTime() >= t.getTime() ? acc : t;
|
|
5816
|
+
}, null);
|
|
5817
|
+
res.json({
|
|
5818
|
+
checks,
|
|
5819
|
+
latestRunAt: latestRunAt ? latestRunAt.toISOString() : null,
|
|
5820
|
+
intervalHours: mailer.config.dnsbl?.intervalHours ?? 24
|
|
5821
|
+
});
|
|
5822
|
+
})
|
|
5823
|
+
);
|
|
5824
|
+
r.post(
|
|
5825
|
+
"/dnsbl/recheck",
|
|
5826
|
+
asyncHandler(async (req, res) => {
|
|
5827
|
+
const result = await runDnsblChecks(mailer.getRunnerContext(), { force: true });
|
|
5828
|
+
await mailer.audit({
|
|
5829
|
+
actor: req.actor,
|
|
5830
|
+
action: "dnsbl.recheck",
|
|
5831
|
+
resource: { collection: "mailer_dnsbl_checks" },
|
|
5832
|
+
diffSummary: result.ran ? `${result.totalChecks} checks, ${result.listedCount} listed` : `not run: ${result.reason}`
|
|
5833
|
+
});
|
|
5834
|
+
res.json(result);
|
|
5835
|
+
})
|
|
5836
|
+
);
|
|
5837
|
+
r.get(
|
|
5838
|
+
"/postmaster",
|
|
5839
|
+
asyncHandler(async (_req, res) => {
|
|
5840
|
+
const cfg = mailer.config.postmaster;
|
|
5841
|
+
const configured = !!cfg?.clientId && !!cfg?.clientSecret && !!cfg?.refreshToken;
|
|
5842
|
+
const all = await c.postmasterSnapshots.find({}).sort({ domain: 1, date: -1 }).limit(2e3).toArray();
|
|
5843
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
5844
|
+
for (const s of all) {
|
|
5845
|
+
const arr = byDomain.get(s.domain) ?? [];
|
|
5846
|
+
if (arr.length < 30) arr.push(s);
|
|
5847
|
+
byDomain.set(s.domain, arr);
|
|
5848
|
+
}
|
|
5849
|
+
const domains = Array.from(byDomain.entries()).map(([domain, snapshots]) => ({
|
|
5850
|
+
domain,
|
|
5851
|
+
latest: snapshots[0] ?? null,
|
|
5852
|
+
history: snapshots
|
|
5853
|
+
}));
|
|
5854
|
+
res.json({
|
|
5855
|
+
configured,
|
|
5856
|
+
intervalHours: cfg?.intervalHours ?? 24,
|
|
5857
|
+
domains
|
|
5858
|
+
});
|
|
5859
|
+
})
|
|
5860
|
+
);
|
|
5861
|
+
r.post(
|
|
5862
|
+
"/postmaster/refresh",
|
|
5863
|
+
asyncHandler(async (req, res) => {
|
|
5864
|
+
const result = await runPostmasterPull(mailer.getRunnerContext(), { force: true });
|
|
5865
|
+
await mailer.audit({
|
|
5866
|
+
actor: req.actor,
|
|
5867
|
+
action: "postmaster.refresh",
|
|
5868
|
+
resource: { collection: "mailer_postmaster_snapshots" },
|
|
5869
|
+
diffSummary: result.ran ? `fetched ${result.fetched ?? 0}, tripped ${(result.trippedDomains ?? []).length}` : `not run: ${result.reason}`
|
|
5870
|
+
});
|
|
5871
|
+
res.json(result);
|
|
5872
|
+
})
|
|
5873
|
+
);
|
|
5874
|
+
let hygieneCache = null;
|
|
5875
|
+
let hygieneInFlight = null;
|
|
5876
|
+
const HYGIENE_CACHE_MS = 6e4;
|
|
5877
|
+
r.get(
|
|
5878
|
+
"/hygiene",
|
|
5879
|
+
asyncHandler(async (req, res) => {
|
|
5880
|
+
const force = req.query.refresh === "1" || req.query.refresh === "true";
|
|
5881
|
+
const now = Date.now();
|
|
5882
|
+
if (!force && hygieneCache && now - hygieneCache.computedAt < HYGIENE_CACHE_MS) {
|
|
5883
|
+
return res.json(hygieneCache.report);
|
|
5884
|
+
}
|
|
5885
|
+
if (!hygieneInFlight) {
|
|
5886
|
+
hygieneInFlight = (async () => {
|
|
5887
|
+
try {
|
|
5888
|
+
const report2 = await computeListHygiene(mailer.getRunnerContext());
|
|
5889
|
+
hygieneCache = { computedAt: Date.now(), report: report2 };
|
|
5890
|
+
return report2;
|
|
5891
|
+
} finally {
|
|
5892
|
+
hygieneInFlight = null;
|
|
5893
|
+
}
|
|
5894
|
+
})();
|
|
5895
|
+
}
|
|
5896
|
+
const report = await hygieneInFlight;
|
|
5897
|
+
res.json(report);
|
|
5898
|
+
})
|
|
5899
|
+
);
|
|
5900
|
+
const dmarcUpload = multer__default.default({
|
|
5901
|
+
storage: multer__default.default.memoryStorage(),
|
|
5902
|
+
limits: { fileSize: 10 * 1024 * 1024 }
|
|
5903
|
+
// 10 MB; reports are typically <100KB
|
|
5904
|
+
});
|
|
5905
|
+
r.post(
|
|
5906
|
+
"/dmarc/upload",
|
|
5907
|
+
dmarcUpload.single("file"),
|
|
5908
|
+
asyncHandler(async (req, res) => {
|
|
5909
|
+
const file = req.file;
|
|
5910
|
+
if (!file) return res.status(400).json({ error: "no_file", message: 'expected a "file" field' });
|
|
5911
|
+
try {
|
|
5912
|
+
const result = await ingestDmarcAttachment(mailer.getRunnerContext(), file.buffer, file.originalname);
|
|
5913
|
+
await mailer.audit({
|
|
5914
|
+
actor: req.actor,
|
|
5915
|
+
action: "dmarc.ingest",
|
|
5916
|
+
resource: { collection: "mailer_dmarc_reports", id: result.reportId },
|
|
5917
|
+
diffSummary: result.duplicate ? `duplicate report ${result.reportId}` : `ingested ${result.totalMessages} msgs (${result.passCount} pass / ${result.failCount} fail) for ${result.domain}`
|
|
5918
|
+
});
|
|
5919
|
+
return res.json(result);
|
|
5920
|
+
} catch (err) {
|
|
5921
|
+
return res.status(400).json({ error: "ingest_failed", message: String(err?.message ?? err) });
|
|
5922
|
+
}
|
|
5923
|
+
})
|
|
5924
|
+
);
|
|
5925
|
+
r.get(
|
|
5926
|
+
"/dmarc",
|
|
5927
|
+
asyncHandler(async (_req, res) => {
|
|
5928
|
+
const ctx = mailer.getRunnerContext();
|
|
5929
|
+
const tagged = await resolveSourceTags(ctx);
|
|
5930
|
+
const reports = await c.dmarcReports.find({}).sort({ rangeEnd: -1 }).limit(200).toArray();
|
|
5931
|
+
const since30 = new Date(Date.now() - 30 * 864e5);
|
|
5932
|
+
const recentFailures = await c.dmarcFailures.find({ receivedAt: { $gte: since30 } }).sort({ receivedAt: -1 }).limit(5e3).toArray();
|
|
5933
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
5934
|
+
for (const r2 of reports) {
|
|
5935
|
+
const cur = byDomain.get(r2.domain) ?? { passCount: 0, failCount: 0, totalMessages: 0, reportCount: 0, latestRangeEnd: null, latestPolicy: null, latestPct: null, reports: [] };
|
|
5936
|
+
cur.passCount += r2.passCount;
|
|
5937
|
+
cur.failCount += r2.failCount;
|
|
5938
|
+
cur.totalMessages += r2.totalMessages;
|
|
5939
|
+
cur.reportCount += 1;
|
|
5940
|
+
const re = new Date(r2.rangeEnd);
|
|
5941
|
+
if (!cur.latestRangeEnd || re > cur.latestRangeEnd) {
|
|
5942
|
+
cur.latestRangeEnd = re;
|
|
5943
|
+
cur.latestPolicy = r2.policyP;
|
|
5944
|
+
cur.latestPct = r2.policyPct;
|
|
5945
|
+
}
|
|
5946
|
+
cur.reports.push(r2);
|
|
5947
|
+
byDomain.set(r2.domain, cur);
|
|
5948
|
+
}
|
|
5949
|
+
const since14 = Date.now() - 14 * 864e5;
|
|
5950
|
+
const domains = Array.from(byDomain.entries()).map(([domain, s]) => {
|
|
5951
|
+
const dayTotals = /* @__PURE__ */ new Map();
|
|
5952
|
+
for (const r2 of s.reports) {
|
|
5953
|
+
if (new Date(r2.rangeEnd).getTime() < since14) continue;
|
|
5954
|
+
const day = new Date(r2.rangeEnd).toISOString().slice(0, 10);
|
|
5955
|
+
const d = dayTotals.get(day) ?? { pass: 0, total: 0 };
|
|
5956
|
+
d.pass += r2.passCount;
|
|
5957
|
+
d.total += r2.passCount + r2.failCount;
|
|
5958
|
+
dayTotals.set(day, d);
|
|
5959
|
+
}
|
|
5960
|
+
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 }));
|
|
5961
|
+
const knownIps = new Set(Array.from(tagged.values()).filter((t) => !t.ignored).map((t) => t.ip));
|
|
5962
|
+
const ignoredIps = new Set(Array.from(tagged.values()).filter((t) => t.ignored).map((t) => t.ip));
|
|
5963
|
+
const suggested = suggestPolicyProgression({
|
|
5964
|
+
reports: s.reports.map((r2) => ({ rangeEnd: new Date(r2.rangeEnd), passCount: r2.passCount, failCount: r2.failCount })),
|
|
5965
|
+
failures: recentFailures.filter((f) => f.domain === domain).map((f) => ({ sourceIp: f.sourceIp, count: f.count, receivedAt: new Date(f.receivedAt) })),
|
|
5966
|
+
knownSourceIps: knownIps,
|
|
5967
|
+
ignoredSourceIps: ignoredIps,
|
|
5968
|
+
currentPolicy: s.latestPolicy,
|
|
5969
|
+
currentPct: s.latestPct
|
|
5970
|
+
});
|
|
5971
|
+
return {
|
|
5972
|
+
domain,
|
|
5973
|
+
passCount: s.passCount,
|
|
5974
|
+
failCount: s.failCount,
|
|
5975
|
+
totalMessages: s.totalMessages,
|
|
5976
|
+
reportCount: s.reportCount,
|
|
5977
|
+
latestRangeEnd: s.latestRangeEnd,
|
|
5978
|
+
alignmentRate: s.totalMessages === 0 ? null : s.passCount / s.totalMessages,
|
|
5979
|
+
currentPolicy: s.latestPolicy,
|
|
5980
|
+
currentPct: s.latestPct,
|
|
5981
|
+
progression: suggested ? { ...suggested, current: { policy: s.latestPolicy, pct: s.latestPct } } : null,
|
|
5982
|
+
series
|
|
5983
|
+
};
|
|
5984
|
+
});
|
|
5985
|
+
const topFailures = await c.dmarcFailures.aggregate([
|
|
5986
|
+
{ $match: { receivedAt: { $gte: since30 } } },
|
|
5987
|
+
{
|
|
5988
|
+
$group: {
|
|
5989
|
+
_id: { sourceIp: "$sourceIp", domain: "$domain" },
|
|
5990
|
+
total: { $sum: "$count" },
|
|
5991
|
+
days: { $addToSet: "$day" },
|
|
5992
|
+
lastSeen: { $max: "$receivedAt" },
|
|
5993
|
+
sample: { $first: "$$ROOT" }
|
|
5994
|
+
}
|
|
5995
|
+
},
|
|
5996
|
+
{ $project: { sourceIp: "$_id.sourceIp", domain: "$_id.domain", total: 1, days: { $size: "$days" }, lastSeen: 1, sample: 1 } },
|
|
5997
|
+
{ $sort: { total: -1 } },
|
|
5998
|
+
{ $limit: 50 }
|
|
5999
|
+
]).toArray();
|
|
6000
|
+
const sources = topFailures.map((f) => {
|
|
6001
|
+
const tag = tagged.get(f._id.sourceIp);
|
|
6002
|
+
return {
|
|
6003
|
+
sourceIp: f._id.sourceIp,
|
|
6004
|
+
domain: f._id.domain,
|
|
6005
|
+
totalMessages: f.total,
|
|
6006
|
+
daysSeen: f.days,
|
|
6007
|
+
lastSeen: f.lastSeen,
|
|
6008
|
+
dkimResult: f.sample.dkimResult,
|
|
6009
|
+
spfResult: f.sample.spfResult,
|
|
6010
|
+
dispositionApplied: f.sample.dispositionApplied,
|
|
6011
|
+
label: tag?.label ?? null,
|
|
6012
|
+
ignored: !!tag?.ignored,
|
|
6013
|
+
tagSource: tag?.source ?? null
|
|
6014
|
+
};
|
|
6015
|
+
});
|
|
6016
|
+
res.json({
|
|
6017
|
+
domains,
|
|
6018
|
+
sources,
|
|
6019
|
+
recentReports: reports.slice(0, 30),
|
|
6020
|
+
retentionDays: mailer.config.dmarc?.retentionDays ?? 90
|
|
6021
|
+
});
|
|
6022
|
+
})
|
|
6023
|
+
);
|
|
6024
|
+
r.put(
|
|
6025
|
+
"/dmarc/sources/:ip",
|
|
6026
|
+
asyncHandler(async (req, res) => {
|
|
6027
|
+
const ip = String(req.params.ip ?? "");
|
|
6028
|
+
if (!net__default.default.isIP(ip)) {
|
|
6029
|
+
return res.status(400).json({ error: "validation_failed", message: "invalid IP" });
|
|
6030
|
+
}
|
|
6031
|
+
const body = req.body ?? {};
|
|
6032
|
+
if (!body.label || typeof body.label !== "string") {
|
|
6033
|
+
return res.status(400).json({ error: "validation_failed", message: "label is required" });
|
|
6034
|
+
}
|
|
6035
|
+
const label = body.label.trim().slice(0, 200);
|
|
6036
|
+
if (!label) {
|
|
6037
|
+
return res.status(400).json({ error: "validation_failed", message: "label is required" });
|
|
6038
|
+
}
|
|
6039
|
+
const ignored = !!body.ignored;
|
|
6040
|
+
await c.dmarcSourceTags.updateOne(
|
|
6041
|
+
{ ip },
|
|
6042
|
+
{ $set: { ip, label, ignored, setBy: req.actor ?? "unknown", setAt: /* @__PURE__ */ new Date() } },
|
|
6043
|
+
{ upsert: true }
|
|
6044
|
+
);
|
|
6045
|
+
await mailer.audit({
|
|
6046
|
+
actor: req.actor,
|
|
6047
|
+
action: "dmarc.source_tag.upsert",
|
|
6048
|
+
resource: { collection: "mailer_dmarc_source_tags", id: ip },
|
|
6049
|
+
diffSummary: `${ip} \u2192 "${label}"${ignored ? " (ignored)" : ""}`
|
|
3608
6050
|
});
|
|
3609
6051
|
res.json({ ok: true });
|
|
3610
6052
|
})
|
|
3611
6053
|
);
|
|
6054
|
+
r.delete(
|
|
6055
|
+
"/dmarc/sources/:ip",
|
|
6056
|
+
asyncHandler(async (req, res) => {
|
|
6057
|
+
const ip = String(req.params.ip ?? "");
|
|
6058
|
+
if (!net__default.default.isIP(ip)) {
|
|
6059
|
+
return res.status(400).json({ error: "validation_failed", message: "invalid IP" });
|
|
6060
|
+
}
|
|
6061
|
+
const result = await c.dmarcSourceTags.deleteOne({ ip });
|
|
6062
|
+
await mailer.audit({
|
|
6063
|
+
actor: req.actor,
|
|
6064
|
+
action: "dmarc.source_tag.delete",
|
|
6065
|
+
resource: { collection: "mailer_dmarc_source_tags", id: ip }
|
|
6066
|
+
});
|
|
6067
|
+
res.json({ ok: true, deleted: result.deletedCount });
|
|
6068
|
+
})
|
|
6069
|
+
);
|
|
6070
|
+
r.get(
|
|
6071
|
+
"/snds",
|
|
6072
|
+
asyncHandler(async (_req, res) => {
|
|
6073
|
+
const cfg = mailer.config.snds;
|
|
6074
|
+
const configured = !!cfg?.accessKey;
|
|
6075
|
+
const all = await c.sndsSnapshots.find({}).sort({ ip: 1, activityStart: -1 }).limit(2e3).toArray();
|
|
6076
|
+
const byIp = /* @__PURE__ */ new Map();
|
|
6077
|
+
for (const s of all) {
|
|
6078
|
+
const arr = byIp.get(s.ip) ?? [];
|
|
6079
|
+
if (arr.length < 30) arr.push(s);
|
|
6080
|
+
byIp.set(s.ip, arr);
|
|
6081
|
+
}
|
|
6082
|
+
const ips = Array.from(byIp.entries()).map(([ip, snapshots]) => ({
|
|
6083
|
+
ip,
|
|
6084
|
+
latest: snapshots[0] ?? null,
|
|
6085
|
+
history: snapshots
|
|
6086
|
+
}));
|
|
6087
|
+
res.json({
|
|
6088
|
+
configured,
|
|
6089
|
+
intervalHours: cfg?.intervalHours ?? 24,
|
|
6090
|
+
ips
|
|
6091
|
+
});
|
|
6092
|
+
})
|
|
6093
|
+
);
|
|
6094
|
+
r.post(
|
|
6095
|
+
"/snds/refresh",
|
|
6096
|
+
asyncHandler(async (req, res) => {
|
|
6097
|
+
const result = await runSndsPull(mailer.getRunnerContext(), { force: true });
|
|
6098
|
+
await mailer.audit({
|
|
6099
|
+
actor: req.actor,
|
|
6100
|
+
action: "snds.refresh",
|
|
6101
|
+
resource: { collection: "mailer_snds_snapshots" },
|
|
6102
|
+
diffSummary: result.ran ? `parsed ${result.rowsParsed ?? 0}, persisted ${result.rowsPersisted ?? 0}` : `not run: ${result.reason}`
|
|
6103
|
+
});
|
|
6104
|
+
res.json(result);
|
|
6105
|
+
})
|
|
6106
|
+
);
|
|
3612
6107
|
r.post(
|
|
3613
6108
|
"/flows",
|
|
3614
6109
|
asyncHandler(async (req, res) => {
|
|
@@ -3849,6 +6344,182 @@ function apiRouter(mailer) {
|
|
|
3849
6344
|
return res.json({ ok: true });
|
|
3850
6345
|
})
|
|
3851
6346
|
);
|
|
6347
|
+
r.get(
|
|
6348
|
+
"/templates/:slug/mail-tester",
|
|
6349
|
+
asyncHandler(async (req, res) => {
|
|
6350
|
+
const cfg = mailer.config.mailTester;
|
|
6351
|
+
const configured = !!cfg?.apiKey;
|
|
6352
|
+
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
6353
|
+
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
6354
|
+
let bodyHash = "";
|
|
6355
|
+
let subject = tpl.subject ?? "";
|
|
6356
|
+
if (tpl.draft) {
|
|
6357
|
+
try {
|
|
6358
|
+
const compiled = tpl.draft.editorJson ? await compileMailyTemplate(tpl.draft.editorJson) : tpl.draft.mjml ? await compileTemplate(tpl.draft.mjml) : null;
|
|
6359
|
+
if (compiled) {
|
|
6360
|
+
bodyHash = sha256Hex(compiled.html);
|
|
6361
|
+
subject = tpl.draft.subject || subject;
|
|
6362
|
+
}
|
|
6363
|
+
} catch {
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
if (!bodyHash && tpl.body?.html) {
|
|
6367
|
+
bodyHash = sha256Hex(tpl.body.html);
|
|
6368
|
+
}
|
|
6369
|
+
const key = bodyHash ? mailTesterContentKey({ bodyHash, subject, fromEmail: tpl.fromEmail }) : null;
|
|
6370
|
+
const cached = key ? await findCachedScore(mailer.getRunnerContext(), key) : null;
|
|
6371
|
+
res.json({
|
|
6372
|
+
configured,
|
|
6373
|
+
minScore: cfg?.minScore ?? 8,
|
|
6374
|
+
cacheHours: cfg?.cacheHours ?? 24,
|
|
6375
|
+
score: cached
|
|
6376
|
+
});
|
|
6377
|
+
})
|
|
6378
|
+
);
|
|
6379
|
+
r.post(
|
|
6380
|
+
"/templates/:slug/mail-tester-check",
|
|
6381
|
+
asyncHandler(async (req, res) => {
|
|
6382
|
+
const client = getMailTesterClient();
|
|
6383
|
+
if (!client) {
|
|
6384
|
+
return res.status(400).json({
|
|
6385
|
+
error: "not_configured",
|
|
6386
|
+
message: "Mail-Tester is not configured. Set mailer.config.mailTester.apiKey to enable."
|
|
6387
|
+
});
|
|
6388
|
+
}
|
|
6389
|
+
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
6390
|
+
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
6391
|
+
const draft = tpl.draft;
|
|
6392
|
+
if (!draft) return res.status(400).json({ error: "no_draft", message: "Save a draft before running a deliverability check." });
|
|
6393
|
+
let compiled;
|
|
6394
|
+
try {
|
|
6395
|
+
if (draft.editorJson) compiled = await compileMailyTemplate(draft.editorJson);
|
|
6396
|
+
else if (draft.mjml) compiled = await compileTemplate(draft.mjml);
|
|
6397
|
+
else return res.status(400).json({ error: "empty_draft" });
|
|
6398
|
+
} catch (err) {
|
|
6399
|
+
return res.status(400).json({ error: "compile_failed", message: String(err?.message ?? err) });
|
|
6400
|
+
}
|
|
6401
|
+
const bodyHash = sha256Hex(compiled.html);
|
|
6402
|
+
const contentKey = mailTesterContentKey({ bodyHash, subject: draft.subject, fromEmail: tpl.fromEmail });
|
|
6403
|
+
const ctx = mailer.getRunnerContext();
|
|
6404
|
+
const cached = await findCachedScore(ctx, contentKey);
|
|
6405
|
+
if (cached) {
|
|
6406
|
+
return res.json({ cached: true, status: "ready", score: cached });
|
|
6407
|
+
}
|
|
6408
|
+
const { checkId, emailAddress } = await client.provisionCheck();
|
|
6409
|
+
const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
|
|
6410
|
+
if (!provider) {
|
|
6411
|
+
return res.status(500).json({ error: "provider_unknown", message: `default provider ${mailer.config.defaultProvider} is not registered` });
|
|
6412
|
+
}
|
|
6413
|
+
try {
|
|
6414
|
+
await provider.send({
|
|
6415
|
+
to: emailAddress,
|
|
6416
|
+
fromName: tpl.fromName,
|
|
6417
|
+
fromEmail: tpl.fromEmail,
|
|
6418
|
+
replyTo: tpl.replyTo ?? void 0,
|
|
6419
|
+
subject: draft.subject,
|
|
6420
|
+
html: compiled.html,
|
|
6421
|
+
text: compiled.plainText,
|
|
6422
|
+
headers: {},
|
|
6423
|
+
messageMeta: { mailTesterCheckId: checkId }
|
|
6424
|
+
});
|
|
6425
|
+
} catch (err) {
|
|
6426
|
+
return res.status(502).json({ error: "send_failed", message: String(err?.message ?? err) });
|
|
6427
|
+
}
|
|
6428
|
+
await mailer.audit({
|
|
6429
|
+
actor: req.actor,
|
|
6430
|
+
action: "mail_tester.check.start",
|
|
6431
|
+
resource: { collection: "mailer_mail_tester_scores", id: contentKey, slug: tpl.slug },
|
|
6432
|
+
diffSummary: `Sent draft to ${emailAddress} for Mail-Tester check ${checkId}`
|
|
6433
|
+
});
|
|
6434
|
+
res.json({
|
|
6435
|
+
cached: false,
|
|
6436
|
+
status: "pending",
|
|
6437
|
+
checkId,
|
|
6438
|
+
contentKey,
|
|
6439
|
+
message: "Test email sent. Poll /mail-tester-result?checkId=...&contentKey=... for the score."
|
|
6440
|
+
});
|
|
6441
|
+
})
|
|
6442
|
+
);
|
|
6443
|
+
r.get(
|
|
6444
|
+
"/templates/:slug/mail-tester-result",
|
|
6445
|
+
asyncHandler(async (req, res) => {
|
|
6446
|
+
const client = getMailTesterClient();
|
|
6447
|
+
if (!client) return res.status(400).json({ error: "not_configured" });
|
|
6448
|
+
const checkId = String(req.query.checkId ?? "");
|
|
6449
|
+
if (!checkId) return res.status(400).json({ error: "validation_failed", message: "checkId is required" });
|
|
6450
|
+
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
6451
|
+
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
6452
|
+
const draft = tpl.draft;
|
|
6453
|
+
if (!draft) return res.status(400).json({ error: "no_draft", message: "Draft no longer exists." });
|
|
6454
|
+
let compiled;
|
|
6455
|
+
try {
|
|
6456
|
+
if (draft.editorJson) compiled = await compileMailyTemplate(draft.editorJson);
|
|
6457
|
+
else if (draft.mjml) compiled = await compileTemplate(draft.mjml);
|
|
6458
|
+
else return res.status(400).json({ error: "empty_draft" });
|
|
6459
|
+
} catch (err) {
|
|
6460
|
+
return res.status(400).json({ error: "compile_failed", message: String(err?.message ?? err) });
|
|
6461
|
+
}
|
|
6462
|
+
const contentKey = mailTesterContentKey({
|
|
6463
|
+
bodyHash: sha256Hex(compiled.html),
|
|
6464
|
+
subject: draft.subject,
|
|
6465
|
+
fromEmail: tpl.fromEmail
|
|
6466
|
+
});
|
|
6467
|
+
const result = await client.fetchResult(checkId);
|
|
6468
|
+
if (!result.ready) {
|
|
6469
|
+
return res.json({ status: "pending", score: null });
|
|
6470
|
+
}
|
|
6471
|
+
const ctx = mailer.getRunnerContext();
|
|
6472
|
+
const persisted = await persistScore(ctx, { templateSlug: tpl.slug, contentKey, checkId, result });
|
|
6473
|
+
await mailer.audit({
|
|
6474
|
+
actor: req.actor,
|
|
6475
|
+
action: "mail_tester.check.complete",
|
|
6476
|
+
resource: { collection: "mailer_mail_tester_scores", id: contentKey, slug: tpl.slug },
|
|
6477
|
+
diffSummary: `Score ${result.score.toFixed(1)}`
|
|
6478
|
+
});
|
|
6479
|
+
res.json({ status: "ready", score: persisted });
|
|
6480
|
+
})
|
|
6481
|
+
);
|
|
6482
|
+
r.post(
|
|
6483
|
+
"/templates/:slug/lint",
|
|
6484
|
+
asyncHandler(async (req, res) => {
|
|
6485
|
+
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
6486
|
+
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
6487
|
+
const body = req.body ?? {};
|
|
6488
|
+
const subject = body.subject ?? tpl.draft?.subject ?? tpl.subject ?? "";
|
|
6489
|
+
const preheader = body.preheader ?? tpl.draft?.preheader ?? tpl.preheader ?? "";
|
|
6490
|
+
const mjml = body.mjml ?? tpl.draft?.mjml ?? tpl.body?.mjml ?? "";
|
|
6491
|
+
const editorJson = body.editorJson !== void 0 ? body.editorJson : tpl.draft?.editorJson ?? tpl.body?.editorJson ?? null;
|
|
6492
|
+
const fromEmail = body.fromEmail ?? tpl.fromEmail;
|
|
6493
|
+
const kind = body.kind === "marketing" || body.kind === "transactional" ? body.kind : tpl.kind;
|
|
6494
|
+
let html = "";
|
|
6495
|
+
let plainText = "";
|
|
6496
|
+
try {
|
|
6497
|
+
if (editorJson) {
|
|
6498
|
+
const compiled = await compileMailyTemplate(editorJson);
|
|
6499
|
+
html = compiled.html;
|
|
6500
|
+
plainText = compiled.plainText;
|
|
6501
|
+
} else if (mjml) {
|
|
6502
|
+
const compiled = await compileTemplate(mjml);
|
|
6503
|
+
html = compiled.html;
|
|
6504
|
+
plainText = compiled.plainText;
|
|
6505
|
+
}
|
|
6506
|
+
} catch (err) {
|
|
6507
|
+
return res.status(200).json({
|
|
6508
|
+
errors: [{ rule: "compile_failed", severity: "error", message: `Compile error: ${String(err?.message ?? err)}` }],
|
|
6509
|
+
warnings: [],
|
|
6510
|
+
infos: [],
|
|
6511
|
+
compileFailed: true
|
|
6512
|
+
});
|
|
6513
|
+
}
|
|
6514
|
+
if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
|
|
6515
|
+
if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
|
|
6516
|
+
const lint = lintTemplate(
|
|
6517
|
+
{ subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
|
|
6518
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
|
|
6519
|
+
);
|
|
6520
|
+
res.json({ ...lint, compileFailed: false });
|
|
6521
|
+
})
|
|
6522
|
+
);
|
|
3852
6523
|
r.post(
|
|
3853
6524
|
"/templates/:slug/publish",
|
|
3854
6525
|
asyncHandler(async (req, res) => {
|
|
@@ -3865,12 +6536,60 @@ function apiRouter(mailer) {
|
|
|
3865
6536
|
});
|
|
3866
6537
|
}
|
|
3867
6538
|
let compiled;
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
6539
|
+
try {
|
|
6540
|
+
if (draft.editorJson) {
|
|
6541
|
+
compiled = await compileMailyTemplate(draft.editorJson);
|
|
6542
|
+
} else if (draft.mjml) {
|
|
6543
|
+
compiled = await compileTemplate(draft.mjml);
|
|
6544
|
+
} else {
|
|
6545
|
+
return res.status(400).json({ error: "empty_draft", message: "draft has no MJML or editorJson content" });
|
|
6546
|
+
}
|
|
6547
|
+
} catch (err) {
|
|
6548
|
+
return res.status(422).json({
|
|
6549
|
+
error: "compile_failed",
|
|
6550
|
+
message: String(err?.message ?? err),
|
|
6551
|
+
lint: {
|
|
6552
|
+
errors: [{ rule: "compile_failed", severity: "error", message: `Compile error: ${String(err?.message ?? err)}` }],
|
|
6553
|
+
warnings: [],
|
|
6554
|
+
infos: []
|
|
6555
|
+
}
|
|
6556
|
+
});
|
|
6557
|
+
}
|
|
6558
|
+
const lint = lintTemplate(
|
|
6559
|
+
{
|
|
6560
|
+
subject: draft.subject,
|
|
6561
|
+
preheader: draft.preheader,
|
|
6562
|
+
mjml: draft.mjml ?? "",
|
|
6563
|
+
editorJson: draft.editorJson,
|
|
6564
|
+
html: compiled.html,
|
|
6565
|
+
plainText: compiled.plainText,
|
|
6566
|
+
kind: tpl.kind,
|
|
6567
|
+
fromEmail: tpl.fromEmail
|
|
6568
|
+
},
|
|
6569
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
|
|
6570
|
+
);
|
|
6571
|
+
if (lint.errors.length > 0) {
|
|
6572
|
+
return res.status(422).json({
|
|
6573
|
+
error: "lint_failed",
|
|
6574
|
+
message: `Template publish blocked by ${lint.errors.length} content issue(s).`,
|
|
6575
|
+
lint
|
|
6576
|
+
});
|
|
6577
|
+
}
|
|
6578
|
+
const bypass = Boolean(req.body?.bypassMailTester);
|
|
6579
|
+
if (!bypass) {
|
|
6580
|
+
const gate = await evaluateMailTesterGate(mailer.getRunnerContext(), {
|
|
6581
|
+
bodyHash: sha256Hex(compiled.html),
|
|
6582
|
+
subject: draft.subject,
|
|
6583
|
+
fromEmail: tpl.fromEmail
|
|
6584
|
+
});
|
|
6585
|
+
if (!gate.allowed) {
|
|
6586
|
+
return res.status(422).json({
|
|
6587
|
+
error: "mail_tester_blocked",
|
|
6588
|
+
message: gate.reason,
|
|
6589
|
+
score: gate.score,
|
|
6590
|
+
hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
|
|
6591
|
+
});
|
|
6592
|
+
}
|
|
3874
6593
|
}
|
|
3875
6594
|
const now = /* @__PURE__ */ new Date();
|
|
3876
6595
|
const nextVersion = await c.templateVersions.countDocuments({ templateId: tpl._id }) + 1;
|
|
@@ -3911,7 +6630,13 @@ function apiRouter(mailer) {
|
|
|
3911
6630
|
resource: { collection: "mailer_templates", id: tpl._id, slug: tpl.slug },
|
|
3912
6631
|
diffSummary: `Published v${nextVersion}`
|
|
3913
6632
|
});
|
|
3914
|
-
return res.json({
|
|
6633
|
+
return res.json({
|
|
6634
|
+
ok: true,
|
|
6635
|
+
version: nextVersion,
|
|
6636
|
+
warnings: compiled.errors,
|
|
6637
|
+
lint
|
|
6638
|
+
// warnings + infos for UI
|
|
6639
|
+
});
|
|
3915
6640
|
})
|
|
3916
6641
|
);
|
|
3917
6642
|
r.post(
|
|
@@ -3933,40 +6658,93 @@ function apiRouter(mailer) {
|
|
|
3933
6658
|
html = tpl.body.html;
|
|
3934
6659
|
plainText = tpl.body.plainText;
|
|
3935
6660
|
}
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
6661
|
+
let contact;
|
|
6662
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
|
|
6663
|
+
if (contactId) {
|
|
6664
|
+
const found = await mailer.adapter.getById(contactId);
|
|
6665
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
|
|
6666
|
+
contact = found;
|
|
6667
|
+
} else {
|
|
6668
|
+
contact = req.body?.sampleContact ?? {
|
|
6669
|
+
externalId: "preview-contact",
|
|
6670
|
+
email: "preview@example.com",
|
|
6671
|
+
tags: [],
|
|
6672
|
+
fields: { firstName: "Alex" }
|
|
6673
|
+
};
|
|
6674
|
+
}
|
|
6675
|
+
const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
|
|
6676
|
+
let resolved = {};
|
|
6677
|
+
try {
|
|
6678
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
6679
|
+
reason: "preview",
|
|
6680
|
+
templateSlug: tpl.slug,
|
|
6681
|
+
eventProperties
|
|
6682
|
+
});
|
|
6683
|
+
} catch (err) {
|
|
6684
|
+
return res.status(502).json({
|
|
6685
|
+
error: "vars_resolve_failed",
|
|
6686
|
+
message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
|
|
6687
|
+
});
|
|
6688
|
+
}
|
|
3942
6689
|
const renderCtx = {
|
|
3943
|
-
|
|
6690
|
+
...resolved,
|
|
6691
|
+
contact,
|
|
3944
6692
|
vars: req.body?.vars ?? {},
|
|
6693
|
+
event: eventProperties ?? {},
|
|
3945
6694
|
unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
|
|
3946
6695
|
senderAddress: mailer.config.senderAddress
|
|
3947
6696
|
};
|
|
3948
6697
|
const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
|
|
3949
6698
|
const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
|
|
3950
|
-
return res.json({
|
|
6699
|
+
return res.json({
|
|
6700
|
+
subject: rendered.subject,
|
|
6701
|
+
preheader: rendered.preheader,
|
|
6702
|
+
html: rendered.html,
|
|
6703
|
+
plainText: rendered.plainText,
|
|
6704
|
+
contact: { externalId: contact.externalId, email: contact.email }
|
|
6705
|
+
});
|
|
3951
6706
|
})
|
|
3952
6707
|
);
|
|
3953
6708
|
r.post(
|
|
3954
6709
|
"/templates/:slug/send-test",
|
|
3955
6710
|
asyncHandler(async (req, res) => {
|
|
3956
|
-
const { to, sampleData } = req.body ?? {};
|
|
6711
|
+
const { to, sampleData, contactId, eventProperties } = req.body ?? {};
|
|
3957
6712
|
if (!to) return res.status(400).json({ error: "to_required" });
|
|
3958
6713
|
const tpl = await c.templates.findOne({ slug: req.params.slug });
|
|
3959
6714
|
if (!tpl) return res.status(404).json({ error: "not_found" });
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
}
|
|
3966
|
-
|
|
6715
|
+
let contact;
|
|
6716
|
+
if (typeof contactId === "string" && contactId) {
|
|
6717
|
+
const found = await mailer.adapter.getById(contactId);
|
|
6718
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
|
|
6719
|
+
contact = { ...found, email: to };
|
|
6720
|
+
} else {
|
|
6721
|
+
contact = sampleData?.contact ?? {
|
|
6722
|
+
externalId: "test-recipient",
|
|
6723
|
+
email: to,
|
|
6724
|
+
tags: [],
|
|
6725
|
+
fields: { firstName: "Test" }
|
|
6726
|
+
};
|
|
6727
|
+
contact.email = to;
|
|
6728
|
+
}
|
|
6729
|
+
const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
|
|
6730
|
+
let resolved = {};
|
|
6731
|
+
try {
|
|
6732
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
6733
|
+
reason: "test",
|
|
6734
|
+
templateSlug: tpl.slug,
|
|
6735
|
+
eventProperties: evProps
|
|
6736
|
+
});
|
|
6737
|
+
} catch (err) {
|
|
6738
|
+
return res.status(502).json({
|
|
6739
|
+
error: "vars_resolve_failed",
|
|
6740
|
+
message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
|
|
6741
|
+
});
|
|
6742
|
+
}
|
|
3967
6743
|
const renderCtx = {
|
|
6744
|
+
...resolved,
|
|
3968
6745
|
contact,
|
|
3969
6746
|
vars: sampleData?.vars ?? {},
|
|
6747
|
+
event: evProps ?? {},
|
|
3970
6748
|
unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
|
|
3971
6749
|
senderAddress: mailer.config.senderAddress
|
|
3972
6750
|
};
|
|
@@ -4617,7 +7395,7 @@ var DEDUPE_POLICIES = [
|
|
|
4617
7395
|
];
|
|
4618
7396
|
|
|
4619
7397
|
// src/server/index.ts
|
|
4620
|
-
var VERSION = "0.
|
|
7398
|
+
var VERSION = "0.7.0";
|
|
4621
7399
|
|
|
4622
7400
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
4623
7401
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|
|
@@ -4630,11 +7408,13 @@ exports.applyTracking = applyTracking;
|
|
|
4630
7408
|
exports.applyWebhookEvent = applyWebhookEvent;
|
|
4631
7409
|
exports.compileMailyTemplate = compileMailyTemplate;
|
|
4632
7410
|
exports.compileTemplate = compileTemplate;
|
|
7411
|
+
exports.computeDeliveryTime = computeDeliveryTime;
|
|
4633
7412
|
exports.createAdminRouter = createAdminRouter;
|
|
4634
7413
|
exports.createPublicRouter = createPublicRouter;
|
|
4635
7414
|
exports.defaultFlowStep = defaultFlowStep;
|
|
4636
7415
|
exports.defaultPredicate = defaultPredicate;
|
|
4637
7416
|
exports.defaultSegmentFilter = defaultSegmentFilter;
|
|
7417
|
+
exports.defineVars = defineVars;
|
|
4638
7418
|
exports.derivePlaintext = derivePlaintext;
|
|
4639
7419
|
exports.dispatchSend = dispatchSend;
|
|
4640
7420
|
exports.ensureIndexes = ensureIndexes;
|
|
@@ -4648,6 +7428,7 @@ exports.sha256Hex = sha256Hex;
|
|
|
4648
7428
|
exports.signUnsubscribeToken = signUnsubscribeToken;
|
|
4649
7429
|
exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
|
|
4650
7430
|
exports.validateSenderDomain = validateSenderDomain;
|
|
7431
|
+
exports.varsJsonSchema = varsJsonSchema;
|
|
4651
7432
|
exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
|
|
4652
7433
|
//# sourceMappingURL=index.cjs.map
|
|
4653
7434
|
//# sourceMappingURL=index.cjs.map
|