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