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.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { MongoClient, ObjectId } from 'mongodb';
|
|
2
3
|
import crypto2 from 'crypto';
|
|
3
4
|
import sgMail from '@sendgrid/mail';
|
|
4
|
-
import { z } from 'zod';
|
|
5
5
|
import IORedis from 'ioredis';
|
|
6
6
|
import Handlebars from 'handlebars';
|
|
7
7
|
import { convert } from 'html-to-text';
|
|
8
8
|
import mjml2html from 'mjml';
|
|
9
|
+
import dns from 'dns/promises';
|
|
10
|
+
import net from 'net';
|
|
11
|
+
import psl from 'psl';
|
|
9
12
|
|
|
10
13
|
var __create = Object.create;
|
|
11
14
|
var __defProp = Object.defineProperty;
|
|
@@ -47,6 +50,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
47
50
|
));
|
|
48
51
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
49
52
|
|
|
53
|
+
// src/server/adapters/vars.ts
|
|
54
|
+
var vars_exports = {};
|
|
55
|
+
__export(vars_exports, {
|
|
56
|
+
RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
|
|
57
|
+
assertNoReservedVarKeys: () => assertNoReservedVarKeys,
|
|
58
|
+
defineVars: () => defineVars,
|
|
59
|
+
resolveVars: () => resolveVars,
|
|
60
|
+
varsJsonSchema: () => varsJsonSchema
|
|
61
|
+
});
|
|
62
|
+
function defineVars(adapter) {
|
|
63
|
+
return adapter;
|
|
64
|
+
}
|
|
65
|
+
function assertNoReservedVarKeys(adapter) {
|
|
66
|
+
const json = varsJsonSchema(adapter);
|
|
67
|
+
const props = json && typeof json === "object" ? json.properties : void 0;
|
|
68
|
+
if (!props) return;
|
|
69
|
+
const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
|
|
70
|
+
if (clashes.length > 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function varsJsonSchema(adapter) {
|
|
77
|
+
return z.toJSONSchema(adapter.schema, { io: "output" });
|
|
78
|
+
}
|
|
79
|
+
async function resolveVars(adapter, contact, info) {
|
|
80
|
+
if (!adapter) return {};
|
|
81
|
+
const resolved = await adapter.resolve(contact, info);
|
|
82
|
+
if (!resolved || typeof resolved !== "object") return {};
|
|
83
|
+
const out = { ...resolved };
|
|
84
|
+
for (const k of RESERVED_VAR_KEYS) delete out[k];
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
var RESERVED_VAR_KEYS;
|
|
88
|
+
var init_vars = __esm({
|
|
89
|
+
"src/server/adapters/vars.ts"() {
|
|
90
|
+
RESERVED_VAR_KEYS = [
|
|
91
|
+
"contact",
|
|
92
|
+
"vars",
|
|
93
|
+
"event",
|
|
94
|
+
"unsubscribeUrl",
|
|
95
|
+
"viewInBrowserUrl",
|
|
96
|
+
"preferenceCenterUrl",
|
|
97
|
+
"senderAddress"
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
50
102
|
// src/server/adapters/mongo.ts
|
|
51
103
|
var mongo_exports = {};
|
|
52
104
|
__export(mongo_exports, {
|
|
@@ -2989,19 +3041,19 @@ var require_range = __commonJS({
|
|
|
2989
3041
|
var replaceCaret = (comp, options2) => {
|
|
2990
3042
|
debug("caret", comp, options2);
|
|
2991
3043
|
const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
|
|
2992
|
-
const
|
|
3044
|
+
const z3 = options2.includePrerelease ? "-0" : "";
|
|
2993
3045
|
return comp.replace(r, (_, M, m, p, pr) => {
|
|
2994
3046
|
debug("caret", comp, _, M, m, p, pr);
|
|
2995
3047
|
let ret;
|
|
2996
3048
|
if (isX(M)) {
|
|
2997
3049
|
ret = "";
|
|
2998
3050
|
} else if (isX(m)) {
|
|
2999
|
-
ret = `>=${M}.0.0${
|
|
3051
|
+
ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
|
|
3000
3052
|
} else if (isX(p)) {
|
|
3001
3053
|
if (M === "0") {
|
|
3002
|
-
ret = `>=${M}.${m}.0${
|
|
3054
|
+
ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
|
|
3003
3055
|
} else {
|
|
3004
|
-
ret = `>=${M}.${m}.0${
|
|
3056
|
+
ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
|
|
3005
3057
|
}
|
|
3006
3058
|
} else if (pr) {
|
|
3007
3059
|
debug("replaceCaret pr", pr);
|
|
@@ -3018,9 +3070,9 @@ var require_range = __commonJS({
|
|
|
3018
3070
|
debug("no pr");
|
|
3019
3071
|
if (M === "0") {
|
|
3020
3072
|
if (m === "0") {
|
|
3021
|
-
ret = `>=${M}.${m}.${p}${
|
|
3073
|
+
ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
|
|
3022
3074
|
} else {
|
|
3023
|
-
ret = `>=${M}.${m}.${p}${
|
|
3075
|
+
ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
|
|
3024
3076
|
}
|
|
3025
3077
|
} else {
|
|
3026
3078
|
ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
|
|
@@ -4155,7 +4207,7 @@ var require_getport = __commonJS({
|
|
|
4155
4207
|
exports.tryPort = tryPort;
|
|
4156
4208
|
exports.resetPortsCache = resetPortsCache;
|
|
4157
4209
|
var tslib_1 = (init_tslib_es6(), __toCommonJS(tslib_es6_exports));
|
|
4158
|
-
var
|
|
4210
|
+
var net2 = tslib_1.__importStar(__require("net"));
|
|
4159
4211
|
var debug_1 = tslib_1.__importDefault(require_src());
|
|
4160
4212
|
var log = (0, debug_1.default)("MongoMS:GetPort");
|
|
4161
4213
|
exports.MIN_PORT = 1024;
|
|
@@ -4203,7 +4255,7 @@ var require_getport = __commonJS({
|
|
|
4203
4255
|
}
|
|
4204
4256
|
function tryPort(port) {
|
|
4205
4257
|
return new Promise((res, rej) => {
|
|
4206
|
-
const server =
|
|
4258
|
+
const server = net2.createServer();
|
|
4207
4259
|
if (typeof server.unref === "function") {
|
|
4208
4260
|
server.unref();
|
|
4209
4261
|
}
|
|
@@ -9169,7 +9221,7 @@ var require_dist = __commonJS({
|
|
|
9169
9221
|
};
|
|
9170
9222
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9171
9223
|
exports.Agent = void 0;
|
|
9172
|
-
var
|
|
9224
|
+
var net2 = __importStar2(__require("net"));
|
|
9173
9225
|
var http = __importStar2(__require("http"));
|
|
9174
9226
|
var https_1 = __require("https");
|
|
9175
9227
|
__exportStar2(require_helpers(), exports);
|
|
@@ -9209,7 +9261,7 @@ var require_dist = __commonJS({
|
|
|
9209
9261
|
if (!this.sockets[name]) {
|
|
9210
9262
|
this.sockets[name] = [];
|
|
9211
9263
|
}
|
|
9212
|
-
const fakeSocket = new
|
|
9264
|
+
const fakeSocket = new net2.Socket({ writable: false });
|
|
9213
9265
|
this.sockets[name].push(fakeSocket);
|
|
9214
9266
|
this.totalSocketCount++;
|
|
9215
9267
|
return fakeSocket;
|
|
@@ -9419,7 +9471,7 @@ var require_dist2 = __commonJS({
|
|
|
9419
9471
|
};
|
|
9420
9472
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9421
9473
|
exports.HttpsProxyAgent = void 0;
|
|
9422
|
-
var
|
|
9474
|
+
var net2 = __importStar2(__require("net"));
|
|
9423
9475
|
var tls = __importStar2(__require("tls"));
|
|
9424
9476
|
var assert_1 = __importDefault2(__require("assert"));
|
|
9425
9477
|
var debug_1 = __importDefault2(require_src());
|
|
@@ -9428,7 +9480,7 @@ var require_dist2 = __commonJS({
|
|
|
9428
9480
|
var parse_proxy_response_1 = require_parse_proxy_response();
|
|
9429
9481
|
var debug = (0, debug_1.default)("https-proxy-agent");
|
|
9430
9482
|
var setServernameFromNonIpHost = (options2) => {
|
|
9431
|
-
if (options2.servername === void 0 && options2.host && !
|
|
9483
|
+
if (options2.servername === void 0 && options2.host && !net2.isIP(options2.host)) {
|
|
9432
9484
|
return {
|
|
9433
9485
|
...options2,
|
|
9434
9486
|
servername: options2.host
|
|
@@ -9468,10 +9520,10 @@ var require_dist2 = __commonJS({
|
|
|
9468
9520
|
socket = tls.connect(setServernameFromNonIpHost(this.connectOpts));
|
|
9469
9521
|
} else {
|
|
9470
9522
|
debug("Creating `net.Socket`: %o", this.connectOpts);
|
|
9471
|
-
socket =
|
|
9523
|
+
socket = net2.connect(this.connectOpts);
|
|
9472
9524
|
}
|
|
9473
9525
|
const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders };
|
|
9474
|
-
const host =
|
|
9526
|
+
const host = net2.isIPv6(opts.host) ? `[${opts.host}]` : opts.host;
|
|
9475
9527
|
let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r
|
|
9476
9528
|
`;
|
|
9477
9529
|
if (proxy.username || proxy.password) {
|
|
@@ -9504,7 +9556,7 @@ var require_dist2 = __commonJS({
|
|
|
9504
9556
|
return socket;
|
|
9505
9557
|
}
|
|
9506
9558
|
socket.destroy();
|
|
9507
|
-
const fakeSocket = new
|
|
9559
|
+
const fakeSocket = new net2.Socket({ writable: false });
|
|
9508
9560
|
fakeSocket.readable = true;
|
|
9509
9561
|
req.once("socket", (s) => {
|
|
9510
9562
|
debug("Replaying proxy buffer for failed request");
|
|
@@ -14140,6 +14192,12 @@ var tagInputSchema = z.object({
|
|
|
14140
14192
|
externalId: externalIdSchema,
|
|
14141
14193
|
tag: z.string().min(1).max(128)
|
|
14142
14194
|
});
|
|
14195
|
+
var abortFlowInputSchema = z.object({
|
|
14196
|
+
flowSlug: slugSchema,
|
|
14197
|
+
externalId: externalIdSchema,
|
|
14198
|
+
reason: z.string().min(1).max(200).optional()
|
|
14199
|
+
});
|
|
14200
|
+
var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
|
|
14143
14201
|
var sendOneOffInputSchema = z.object({
|
|
14144
14202
|
templateSlug: slugSchema,
|
|
14145
14203
|
externalId: externalIdSchema,
|
|
@@ -14169,7 +14227,13 @@ var flowStepSchema = z.lazy(
|
|
|
14169
14227
|
type: z.literal("send"),
|
|
14170
14228
|
templateSlug: slugSchema,
|
|
14171
14229
|
providerOverride: z.string().optional(),
|
|
14172
|
-
vars: z.record(z.string(), z.unknown()).optional()
|
|
14230
|
+
vars: z.record(z.string(), z.unknown()).optional(),
|
|
14231
|
+
delivery: z.object({
|
|
14232
|
+
weekdaysOnly: z.boolean().optional(),
|
|
14233
|
+
timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
|
|
14234
|
+
useContactTimezone: z.boolean().optional(),
|
|
14235
|
+
timezone: z.string().optional()
|
|
14236
|
+
}).optional()
|
|
14173
14237
|
}),
|
|
14174
14238
|
z.object({
|
|
14175
14239
|
type: z.literal("tag"),
|
|
@@ -14253,6 +14317,17 @@ var predicateSchema = z.lazy(
|
|
|
14253
14317
|
);
|
|
14254
14318
|
|
|
14255
14319
|
// src/server/config.ts
|
|
14320
|
+
var DEFAULT_DOMAIN_DNSBL_LISTS = [
|
|
14321
|
+
{ host: "dbl.spamhaus.org", label: "Spamhaus DBL" },
|
|
14322
|
+
{ host: "multi.surbl.org", label: "SURBL" },
|
|
14323
|
+
{ host: "multi.uribl.com", label: "URIBL" }
|
|
14324
|
+
];
|
|
14325
|
+
var DEFAULT_IP_DNSBL_LISTS = [
|
|
14326
|
+
{ host: "zen.spamhaus.org", label: "Spamhaus ZEN" },
|
|
14327
|
+
{ host: "b.barracudacentral.org", label: "Barracuda" },
|
|
14328
|
+
{ host: "dnsbl.sorbs.net", label: "SORBS" },
|
|
14329
|
+
{ host: "bl.spamcop.net", label: "SpamCop" }
|
|
14330
|
+
];
|
|
14256
14331
|
var DEFAULTS = {
|
|
14257
14332
|
collectionPrefix: "mailer_",
|
|
14258
14333
|
requireDoubleOptIn: false,
|
|
@@ -14313,6 +14388,10 @@ function resolveConfig(c) {
|
|
|
14313
14388
|
}
|
|
14314
14389
|
|
|
14315
14390
|
// src/server/models/index.ts
|
|
14391
|
+
var HEALTH_AGG_ID = "agg";
|
|
14392
|
+
function healthBucketId(senderDomain, kind) {
|
|
14393
|
+
return `d:${senderDomain ?? "_unknown"}|k:${kind}`;
|
|
14394
|
+
}
|
|
14316
14395
|
function getCollections(db, prefix = "mailer_") {
|
|
14317
14396
|
return {
|
|
14318
14397
|
subscriptions: db.collection(`${prefix}subscriptions`),
|
|
@@ -14330,7 +14409,14 @@ function getCollections(db, prefix = "mailer_") {
|
|
|
14330
14409
|
auditLog: db.collection(`${prefix}audit_log`),
|
|
14331
14410
|
webhookEvents: db.collection(`${prefix}webhook_events`),
|
|
14332
14411
|
health: db.collection(`${prefix}health`),
|
|
14333
|
-
contactTags: db.collection(`${prefix}contact_tags`)
|
|
14412
|
+
contactTags: db.collection(`${prefix}contact_tags`),
|
|
14413
|
+
dnsblChecks: db.collection(`${prefix}dnsbl_checks`),
|
|
14414
|
+
postmasterSnapshots: db.collection(`${prefix}postmaster_snapshots`),
|
|
14415
|
+
sndsSnapshots: db.collection(`${prefix}snds_snapshots`),
|
|
14416
|
+
dmarcReports: db.collection(`${prefix}dmarc_reports`),
|
|
14417
|
+
dmarcFailures: db.collection(`${prefix}dmarc_failures`),
|
|
14418
|
+
dmarcSourceTags: db.collection(`${prefix}dmarc_source_tags`),
|
|
14419
|
+
mailTesterScores: db.collection(`${prefix}mail_tester_scores`)
|
|
14334
14420
|
};
|
|
14335
14421
|
}
|
|
14336
14422
|
async function ensureIndexes(db, prefix = "mailer_") {
|
|
@@ -14402,8 +14488,58 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14402
14488
|
c.contactTags.createIndexes([
|
|
14403
14489
|
{ key: { externalId: 1, tag: 1 }, unique: true },
|
|
14404
14490
|
{ key: { tag: 1 } }
|
|
14491
|
+
]),
|
|
14492
|
+
c.health.createIndexes([
|
|
14493
|
+
{ key: { senderDomain: 1, kind: 1 } },
|
|
14494
|
+
{ key: { status: 1 } },
|
|
14495
|
+
// setup-status reads the most-recently-touched health doc as a heartbeat.
|
|
14496
|
+
{ key: { updatedAt: -1 } }
|
|
14497
|
+
]),
|
|
14498
|
+
c.dnsblChecks.createIndexes([
|
|
14499
|
+
{ key: { target: 1, list: 1 }, unique: true },
|
|
14500
|
+
// Supports the admin /dnsbl GET sort: result asc, then target asc, list asc.
|
|
14501
|
+
{ key: { result: 1, target: 1, list: 1 } },
|
|
14502
|
+
// TTL — stale rows for targets the operator removed disappear after
|
|
14503
|
+
// 60 days without manual cleanup. The puller refreshes runAt on
|
|
14504
|
+
// every active target so existing targets stay indefinitely.
|
|
14505
|
+
{ key: { runAt: 1 }, expireAfterSeconds: 60 * 24 * 60 * 60 }
|
|
14506
|
+
]),
|
|
14507
|
+
c.postmasterSnapshots.createIndexes([
|
|
14508
|
+
{ key: { domain: 1, date: 1 }, unique: true },
|
|
14509
|
+
{ key: { domain: 1, fetchedAt: -1 } },
|
|
14510
|
+
{ key: { domainReputation: 1 } }
|
|
14511
|
+
]),
|
|
14512
|
+
c.sndsSnapshots.createIndexes([
|
|
14513
|
+
{ key: { ip: 1, activityStart: 1 }, unique: true },
|
|
14514
|
+
{ key: { ip: 1, fetchedAt: -1 } },
|
|
14515
|
+
{ key: { filterResult: 1 } }
|
|
14516
|
+
]),
|
|
14517
|
+
c.dmarcReports.createIndexes([
|
|
14518
|
+
{ key: { reportId: 1, orgName: 1 }, unique: true },
|
|
14519
|
+
{ key: { domain: 1, rangeEnd: -1 } },
|
|
14520
|
+
// Cross-domain "most recent reports" queries scan a lot without this.
|
|
14521
|
+
{ key: { rangeEnd: -1 } },
|
|
14522
|
+
{ key: { receivedAt: -1 } }
|
|
14523
|
+
]),
|
|
14524
|
+
c.dmarcFailures.createIndexes([
|
|
14525
|
+
{ key: { reportId: 1, sourceIp: 1 }, unique: true },
|
|
14526
|
+
{ key: { domain: 1, day: -1 } },
|
|
14527
|
+
{ key: { sourceIp: 1, day: -1 } },
|
|
14528
|
+
{ key: { receivedAt: 1 } }
|
|
14529
|
+
// for retention pruning
|
|
14530
|
+
]),
|
|
14531
|
+
c.dmarcSourceTags.createIndexes([
|
|
14532
|
+
{ key: { ip: 1 }, unique: true }
|
|
14533
|
+
]),
|
|
14534
|
+
c.mailTesterScores.createIndexes([
|
|
14535
|
+
{ key: { contentKey: 1 }, unique: true },
|
|
14536
|
+
{ key: { templateSlug: 1, fetchedAt: -1 } },
|
|
14537
|
+
// TTL — Mongo auto-deletes expired scores so we never serve stale data.
|
|
14538
|
+
{ key: { expiresAt: 1 }, expireAfterSeconds: 0 }
|
|
14405
14539
|
])
|
|
14406
14540
|
]);
|
|
14541
|
+
await c.health.deleteOne({ _id: "singleton" }).catch(() => {
|
|
14542
|
+
});
|
|
14407
14543
|
}
|
|
14408
14544
|
var EventRegistry = class {
|
|
14409
14545
|
policies = /* @__PURE__ */ new Map();
|
|
@@ -14416,6 +14552,9 @@ var EventRegistry = class {
|
|
|
14416
14552
|
policy(name) {
|
|
14417
14553
|
return this.policies.get(name);
|
|
14418
14554
|
}
|
|
14555
|
+
list() {
|
|
14556
|
+
return Array.from(this.policies, ([name, dedupePolicy]) => ({ name, dedupePolicy }));
|
|
14557
|
+
}
|
|
14419
14558
|
/**
|
|
14420
14559
|
* Derive a dedupeKey for an event call. Returns null when no policy is
|
|
14421
14560
|
* registered AND no key was passed — caller should throw.
|
|
@@ -14829,6 +14968,7 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14829
14968
|
flowSlug: flow.slug,
|
|
14830
14969
|
flowVersion: flow.version,
|
|
14831
14970
|
emailAtEntry: sub.emailAtSubscribe,
|
|
14971
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
14832
14972
|
enteredAt: /* @__PURE__ */ new Date(),
|
|
14833
14973
|
status: "active",
|
|
14834
14974
|
currentStepIndex: 0,
|
|
@@ -14926,6 +15066,105 @@ function effectiveLowerBound(ctx, opts) {
|
|
|
14926
15066
|
}
|
|
14927
15067
|
return null;
|
|
14928
15068
|
}
|
|
15069
|
+
|
|
15070
|
+
// src/server/runner/delivery-window.ts
|
|
15071
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15072
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15073
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15074
|
+
let candidate = now;
|
|
15075
|
+
if (window2.timeOfDay) {
|
|
15076
|
+
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15077
|
+
const local = localParts(candidate, tz);
|
|
15078
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
15079
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
15080
|
+
candidate = todaySlot;
|
|
15081
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
15082
|
+
const next = addLocalDays(local, 1);
|
|
15083
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
15084
|
+
}
|
|
15085
|
+
}
|
|
15086
|
+
if (window2.weekdaysOnly) {
|
|
15087
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
15088
|
+
const local = localParts(candidate, tz);
|
|
15089
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
15090
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
15091
|
+
const moved = addLocalDays(local, shift);
|
|
15092
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
15093
|
+
}
|
|
15094
|
+
}
|
|
15095
|
+
return candidate;
|
|
15096
|
+
}
|
|
15097
|
+
function pickTimezone(window2, contactTimezone) {
|
|
15098
|
+
const candidates = [
|
|
15099
|
+
window2.useContactTimezone ? contactTimezone : void 0,
|
|
15100
|
+
window2.timezone,
|
|
15101
|
+
"UTC"
|
|
15102
|
+
];
|
|
15103
|
+
for (const tz of candidates) {
|
|
15104
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
15105
|
+
}
|
|
15106
|
+
return "UTC";
|
|
15107
|
+
}
|
|
15108
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
15109
|
+
function isValidTimezone(tz) {
|
|
15110
|
+
const cached = validatedZones.get(tz);
|
|
15111
|
+
if (cached !== void 0) return cached;
|
|
15112
|
+
let ok = true;
|
|
15113
|
+
try {
|
|
15114
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
15115
|
+
} catch {
|
|
15116
|
+
ok = false;
|
|
15117
|
+
}
|
|
15118
|
+
validatedZones.set(tz, ok);
|
|
15119
|
+
return ok;
|
|
15120
|
+
}
|
|
15121
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
15122
|
+
function formatterFor(tz) {
|
|
15123
|
+
let f = partFormatters.get(tz);
|
|
15124
|
+
if (!f) {
|
|
15125
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
15126
|
+
timeZone: tz,
|
|
15127
|
+
year: "numeric",
|
|
15128
|
+
month: "2-digit",
|
|
15129
|
+
day: "2-digit",
|
|
15130
|
+
hour: "2-digit",
|
|
15131
|
+
minute: "2-digit",
|
|
15132
|
+
second: "2-digit",
|
|
15133
|
+
weekday: "short",
|
|
15134
|
+
hour12: false
|
|
15135
|
+
});
|
|
15136
|
+
partFormatters.set(tz, f);
|
|
15137
|
+
}
|
|
15138
|
+
return f;
|
|
15139
|
+
}
|
|
15140
|
+
function localParts(date, tz) {
|
|
15141
|
+
const parts = {};
|
|
15142
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
15143
|
+
return {
|
|
15144
|
+
y: Number(parts.year),
|
|
15145
|
+
mo: Number(parts.month),
|
|
15146
|
+
d: Number(parts.day),
|
|
15147
|
+
hh: Number(parts.hour) % 24,
|
|
15148
|
+
// Intl emits '24' for midnight in some locales
|
|
15149
|
+
mi: Number(parts.minute),
|
|
15150
|
+
ss: Number(parts.second),
|
|
15151
|
+
weekday: parts.weekday
|
|
15152
|
+
};
|
|
15153
|
+
}
|
|
15154
|
+
function utcFromLocal(y, mo, d, hh, mi, tz) {
|
|
15155
|
+
let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
|
|
15156
|
+
for (let i = 0; i < 2; i++) {
|
|
15157
|
+
const p = localParts(new Date(ts), tz);
|
|
15158
|
+
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
|
|
15159
|
+
const offset = asUtc - ts;
|
|
15160
|
+
ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
|
|
15161
|
+
}
|
|
15162
|
+
return new Date(ts);
|
|
15163
|
+
}
|
|
15164
|
+
function addLocalDays(p, days) {
|
|
15165
|
+
const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
|
|
15166
|
+
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
15167
|
+
}
|
|
14929
15168
|
async function compileTemplate(mjml) {
|
|
14930
15169
|
const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
|
|
14931
15170
|
const plainText = derivePlaintext(out.html);
|
|
@@ -15043,6 +15282,9 @@ function makeHandlebars(extra) {
|
|
|
15043
15282
|
return hb;
|
|
15044
15283
|
}
|
|
15045
15284
|
|
|
15285
|
+
// src/server/runner/send.ts
|
|
15286
|
+
init_vars();
|
|
15287
|
+
|
|
15046
15288
|
// src/server/runner/suppression.ts
|
|
15047
15289
|
var SCOPES_BY_KIND = {
|
|
15048
15290
|
marketing: ["all", "marketing"],
|
|
@@ -15065,21 +15307,59 @@ async function isSuppressed(collections, email, kind) {
|
|
|
15065
15307
|
return { suppressed: false };
|
|
15066
15308
|
}
|
|
15067
15309
|
|
|
15310
|
+
// src/server/templates/sender-domain.ts
|
|
15311
|
+
function extractDomain(email) {
|
|
15312
|
+
if (typeof email !== "string") return null;
|
|
15313
|
+
const at = email.lastIndexOf("@");
|
|
15314
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15315
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15316
|
+
}
|
|
15317
|
+
|
|
15068
15318
|
// src/server/runner/health.ts
|
|
15069
|
-
|
|
15319
|
+
var ZERO_COUNTERS = {
|
|
15320
|
+
sent: 0,
|
|
15321
|
+
delivered: 0,
|
|
15322
|
+
bounced: 0,
|
|
15323
|
+
hardBounced: 0,
|
|
15324
|
+
softBounced: 0,
|
|
15325
|
+
complained: 0,
|
|
15326
|
+
failedToSend: 0
|
|
15327
|
+
};
|
|
15328
|
+
var ZERO_RATES = {
|
|
15329
|
+
bounceRate: 0,
|
|
15330
|
+
hardBounceRate: 0,
|
|
15331
|
+
complaintRate: 0,
|
|
15332
|
+
failureRate: 0
|
|
15333
|
+
};
|
|
15334
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15335
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15336
|
+
const writes = [];
|
|
15337
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15338
|
+
if (dims) {
|
|
15339
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15340
|
+
if (domain) {
|
|
15341
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15342
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15343
|
+
}
|
|
15344
|
+
}
|
|
15345
|
+
await Promise.all(writes);
|
|
15346
|
+
}
|
|
15347
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15070
15348
|
await ctx.collections.health.updateOne(
|
|
15071
|
-
{ _id
|
|
15349
|
+
{ _id },
|
|
15072
15350
|
{
|
|
15073
15351
|
$inc: { [`counters.${counter2}`]: by },
|
|
15074
15352
|
$setOnInsert: {
|
|
15075
|
-
_id
|
|
15353
|
+
_id,
|
|
15354
|
+
senderDomain,
|
|
15355
|
+
kind,
|
|
15076
15356
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15077
|
-
windowDurationMs:
|
|
15357
|
+
windowDurationMs: windowMs,
|
|
15078
15358
|
status: "healthy",
|
|
15079
15359
|
trippedAt: null,
|
|
15080
15360
|
trippedReason: null,
|
|
15081
15361
|
manuallyResumedAt: null,
|
|
15082
|
-
rates: {
|
|
15362
|
+
rates: { ...ZERO_RATES }
|
|
15083
15363
|
},
|
|
15084
15364
|
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15085
15365
|
},
|
|
@@ -15089,73 +15369,107 @@ async function recordHealthCounter(ctx, counter2, by = 1) {
|
|
|
15089
15369
|
async function evaluateHealth(ctx) {
|
|
15090
15370
|
const cb = ctx.config.circuitBreaker;
|
|
15091
15371
|
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15092
|
-
const
|
|
15093
|
-
if (
|
|
15094
|
-
const
|
|
15095
|
-
if (
|
|
15096
|
-
await ctx
|
|
15097
|
-
{ _id: "singleton" },
|
|
15098
|
-
{
|
|
15099
|
-
$set: {
|
|
15100
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15101
|
-
windowDurationMs: windowMs,
|
|
15102
|
-
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
15103
|
-
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 },
|
|
15104
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
15105
|
-
}
|
|
15106
|
-
}
|
|
15107
|
-
);
|
|
15108
|
-
return;
|
|
15372
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15373
|
+
if (docs.length === 0) return;
|
|
15374
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15375
|
+
if (!hasAgg) {
|
|
15376
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15109
15377
|
}
|
|
15110
|
-
const
|
|
15111
|
-
|
|
15112
|
-
|
|
15113
|
-
|
|
15114
|
-
|
|
15115
|
-
|
|
15116
|
-
|
|
15117
|
-
|
|
15118
|
-
|
|
15119
|
-
|
|
15120
|
-
|
|
15121
|
-
|
|
15122
|
-
|
|
15123
|
-
|
|
15124
|
-
|
|
15125
|
-
|
|
15126
|
-
|
|
15127
|
-
|
|
15128
|
-
|
|
15129
|
-
|
|
15130
|
-
|
|
15131
|
-
|
|
15132
|
-
|
|
15378
|
+
for (const doc of docs) {
|
|
15379
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15380
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15381
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15382
|
+
await ctx.collections.health.updateOne(
|
|
15383
|
+
{ _id: doc._id },
|
|
15384
|
+
{
|
|
15385
|
+
$set: {
|
|
15386
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15387
|
+
windowDurationMs: windowMs,
|
|
15388
|
+
counters: { ...ZERO_COUNTERS },
|
|
15389
|
+
rates: { ...ZERO_RATES },
|
|
15390
|
+
status: "healthy",
|
|
15391
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15392
|
+
}
|
|
15393
|
+
}
|
|
15394
|
+
);
|
|
15395
|
+
continue;
|
|
15396
|
+
}
|
|
15397
|
+
const c = doc.counters;
|
|
15398
|
+
const total = c.sent || 1;
|
|
15399
|
+
const rates = {
|
|
15400
|
+
bounceRate: c.bounced / total,
|
|
15401
|
+
hardBounceRate: c.hardBounced / total,
|
|
15402
|
+
complaintRate: c.complained / total,
|
|
15403
|
+
failureRate: c.failedToSend / total
|
|
15404
|
+
};
|
|
15133
15405
|
await ctx.collections.health.updateOne(
|
|
15134
|
-
{ _id:
|
|
15135
|
-
{ $set: {
|
|
15406
|
+
{ _id: doc._id },
|
|
15407
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15136
15408
|
);
|
|
15137
|
-
if (
|
|
15138
|
-
|
|
15139
|
-
|
|
15140
|
-
|
|
15409
|
+
if (isAgg) continue;
|
|
15410
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15411
|
+
if (doc.status === "tripped") continue;
|
|
15412
|
+
let trippedReason = null;
|
|
15413
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15414
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15415
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15416
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15417
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15418
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15419
|
+
}
|
|
15420
|
+
if (trippedReason) {
|
|
15421
|
+
const result = await ctx.collections.health.updateOne(
|
|
15422
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15423
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15424
|
+
);
|
|
15425
|
+
if (result.modifiedCount > 0) {
|
|
15426
|
+
if (ctx.audit) {
|
|
15427
|
+
try {
|
|
15428
|
+
await ctx.audit({
|
|
15429
|
+
actor: "system:circuit-breaker",
|
|
15430
|
+
action: "health.trip",
|
|
15431
|
+
resource: {
|
|
15432
|
+
collection: "mailer_health",
|
|
15433
|
+
id: String(doc._id),
|
|
15434
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15435
|
+
},
|
|
15436
|
+
diffSummary: trippedReason
|
|
15437
|
+
});
|
|
15438
|
+
} catch {
|
|
15439
|
+
}
|
|
15440
|
+
}
|
|
15441
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15442
|
+
try {
|
|
15443
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15444
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15445
|
+
rates
|
|
15446
|
+
});
|
|
15447
|
+
} catch {
|
|
15448
|
+
}
|
|
15449
|
+
}
|
|
15141
15450
|
}
|
|
15451
|
+
continue;
|
|
15142
15452
|
}
|
|
15143
|
-
|
|
15144
|
-
|
|
15145
|
-
|
|
15146
|
-
|
|
15453
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15454
|
+
if (doc.status !== "degraded") {
|
|
15455
|
+
await ctx.collections.health.updateOne(
|
|
15456
|
+
{ _id: doc._id },
|
|
15457
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15458
|
+
);
|
|
15459
|
+
}
|
|
15460
|
+
} else if (doc.status === "degraded") {
|
|
15147
15461
|
await ctx.collections.health.updateOne(
|
|
15148
|
-
{ _id:
|
|
15149
|
-
{ $set: { status: "
|
|
15462
|
+
{ _id: doc._id },
|
|
15463
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15150
15464
|
);
|
|
15151
15465
|
}
|
|
15152
|
-
} else if (doc.status === "degraded") {
|
|
15153
|
-
await ctx.collections.health.updateOne(
|
|
15154
|
-
{ _id: "singleton" },
|
|
15155
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15156
|
-
);
|
|
15157
15466
|
}
|
|
15158
15467
|
}
|
|
15468
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15469
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15470
|
+
const id = healthBucketId(domain, kind);
|
|
15471
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15472
|
+
}
|
|
15159
15473
|
|
|
15160
15474
|
// src/server/runner/send.ts
|
|
15161
15475
|
async function handleSend(run, step, contact, flow, ctx) {
|
|
@@ -15239,8 +15553,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15239
15553
|
return;
|
|
15240
15554
|
}
|
|
15241
15555
|
if (send.kind === "marketing") {
|
|
15242
|
-
const
|
|
15243
|
-
if (
|
|
15556
|
+
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15557
|
+
if (bucket?.status === "tripped") {
|
|
15244
15558
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15245
15559
|
return;
|
|
15246
15560
|
}
|
|
@@ -15251,13 +15565,29 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15251
15565
|
return;
|
|
15252
15566
|
}
|
|
15253
15567
|
const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
|
|
15254
|
-
|
|
15255
|
-
|
|
15256
|
-
|
|
15257
|
-
|
|
15258
|
-
|
|
15259
|
-
|
|
15260
|
-
|
|
15568
|
+
if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
|
|
15569
|
+
await ctx.collections.sends.updateOne(
|
|
15570
|
+
{ _id: send._id },
|
|
15571
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15572
|
+
);
|
|
15573
|
+
return;
|
|
15574
|
+
}
|
|
15575
|
+
let renderCtx;
|
|
15576
|
+
let rendered;
|
|
15577
|
+
try {
|
|
15578
|
+
const resolved = await resolveVars(ctx.varsAdapter, contact, {
|
|
15579
|
+
reason: "send",
|
|
15580
|
+
templateSlug: template.slug,
|
|
15581
|
+
flowSlug: run?.flowSlug,
|
|
15582
|
+
eventName: run?.triggerEvent?.name,
|
|
15583
|
+
eventProperties: run?.triggerEvent?.properties
|
|
15584
|
+
});
|
|
15585
|
+
renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
|
|
15586
|
+
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
15587
|
+
} catch (err) {
|
|
15588
|
+
await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
|
|
15589
|
+
throw err;
|
|
15590
|
+
}
|
|
15261
15591
|
const tracking = applyTracking(rendered.html, {
|
|
15262
15592
|
sendId: String(send._id),
|
|
15263
15593
|
publicUrl: ctx.config.publicUrl,
|
|
@@ -15309,13 +15639,13 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15309
15639
|
}
|
|
15310
15640
|
}
|
|
15311
15641
|
);
|
|
15312
|
-
await recordHealthCounter(ctx, "sent");
|
|
15642
|
+
await recordHealthCounter(ctx, "sent", { fromEmail: send.fromEmail, kind: send.kind });
|
|
15313
15643
|
} catch (err) {
|
|
15314
15644
|
await ctx.collections.sends.updateOne(
|
|
15315
15645
|
{ _id: send._id },
|
|
15316
15646
|
{ $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
|
|
15317
15647
|
);
|
|
15318
|
-
await recordHealthCounter(ctx, "failedToSend");
|
|
15648
|
+
await recordHealthCounter(ctx, "failedToSend", { fromEmail: send.fromEmail, kind: send.kind });
|
|
15319
15649
|
if (ctx.config.onSendFailure) {
|
|
15320
15650
|
try {
|
|
15321
15651
|
await ctx.config.onSendFailure({ send, error: err });
|
|
@@ -15333,7 +15663,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
|
|
|
15333
15663
|
}
|
|
15334
15664
|
return ctx.config.defaultProvider;
|
|
15335
15665
|
}
|
|
15336
|
-
function buildRenderContext(contact, run, vars, ctx) {
|
|
15666
|
+
function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
|
|
15337
15667
|
const scope = "marketing";
|
|
15338
15668
|
const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
15339
15669
|
const token = signUnsubscribeToken(
|
|
@@ -15342,8 +15672,10 @@ function buildRenderContext(contact, run, vars, ctx) {
|
|
|
15342
15672
|
);
|
|
15343
15673
|
const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
|
|
15344
15674
|
return {
|
|
15675
|
+
...resolved,
|
|
15345
15676
|
contact,
|
|
15346
15677
|
vars,
|
|
15678
|
+
event: run?.triggerEvent?.properties ?? {},
|
|
15347
15679
|
unsubscribeUrl,
|
|
15348
15680
|
senderAddress: ctx.config.senderAddress
|
|
15349
15681
|
};
|
|
@@ -15428,8 +15760,15 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15428
15760
|
return handleCondition(run, step, contact, ctx);
|
|
15429
15761
|
case "branch":
|
|
15430
15762
|
return handleBranch(run, step, contact, ctx);
|
|
15431
|
-
case "send":
|
|
15763
|
+
case "send": {
|
|
15764
|
+
if (step.delivery) {
|
|
15765
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
|
|
15766
|
+
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
15767
|
+
return deferSendForWindow(run, deliverAt, ctx);
|
|
15768
|
+
}
|
|
15769
|
+
}
|
|
15432
15770
|
return handleSend(run, step, contact, flow, ctx);
|
|
15771
|
+
}
|
|
15433
15772
|
case "tag":
|
|
15434
15773
|
return handleTag(run, step, ctx);
|
|
15435
15774
|
case "fire_event":
|
|
@@ -15580,6 +15919,34 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
15580
15919
|
}
|
|
15581
15920
|
}
|
|
15582
15921
|
}
|
|
15922
|
+
async function deferSendForWindow(run, deliverAt, ctx) {
|
|
15923
|
+
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15924
|
+
// Only write once per deferral — if nextActionAt already points at (or
|
|
15925
|
+
// past) the slot, another worker/tick got here first.
|
|
15926
|
+
{ _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
|
|
15927
|
+
{
|
|
15928
|
+
$set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
|
|
15929
|
+
$push: {
|
|
15930
|
+
history: {
|
|
15931
|
+
stepIndex: run.currentStepIndex,
|
|
15932
|
+
action: "send_deferred",
|
|
15933
|
+
at: /* @__PURE__ */ new Date(),
|
|
15934
|
+
details: { until: deliverAt }
|
|
15935
|
+
}
|
|
15936
|
+
}
|
|
15937
|
+
},
|
|
15938
|
+
{ returnDocument: "after" }
|
|
15939
|
+
);
|
|
15940
|
+
if (!updated) return;
|
|
15941
|
+
await ctx.queues.advance.add(
|
|
15942
|
+
"advance",
|
|
15943
|
+
{ flowRunId: String(run._id) },
|
|
15944
|
+
{
|
|
15945
|
+
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15946
|
+
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15947
|
+
}
|
|
15948
|
+
);
|
|
15949
|
+
}
|
|
15583
15950
|
async function advanceStep(run, ctx, log, opts = {}) {
|
|
15584
15951
|
const stepInc = opts.stepInc ?? 1;
|
|
15585
15952
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
@@ -15899,7 +16266,15 @@ async function promoteSoftBounces(ctx) {
|
|
|
15899
16266
|
const cutoff = new Date(Date.now() - windowDays * 864e5);
|
|
15900
16267
|
const offenders = await ctx.collections.sends.aggregate([
|
|
15901
16268
|
{ $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
|
|
15902
|
-
{ $
|
|
16269
|
+
{ $sort: { queuedAt: 1 } },
|
|
16270
|
+
{
|
|
16271
|
+
$group: {
|
|
16272
|
+
_id: "$emailAtSend",
|
|
16273
|
+
count: { $sum: 1 },
|
|
16274
|
+
lastFromEmail: { $last: "$fromEmail" },
|
|
16275
|
+
lastKind: { $last: "$kind" }
|
|
16276
|
+
}
|
|
16277
|
+
},
|
|
15903
16278
|
{ $match: { count: { $gte: threshold } } },
|
|
15904
16279
|
{ $limit: 200 }
|
|
15905
16280
|
]).toArray();
|
|
@@ -15931,32 +16306,649 @@ async function promoteSoftBounces(ctx) {
|
|
|
15931
16306
|
{ emailAtSubscribe: email },
|
|
15932
16307
|
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15933
16308
|
);
|
|
15934
|
-
await recordHealthCounter(
|
|
16309
|
+
await recordHealthCounter(
|
|
16310
|
+
ctx,
|
|
16311
|
+
"hardBounced",
|
|
16312
|
+
o.lastKind ? { fromEmail: o.lastFromEmail, kind: o.lastKind } : null
|
|
16313
|
+
);
|
|
16314
|
+
}
|
|
16315
|
+
}
|
|
16316
|
+
var REGISTRABLE_ONLY_LISTS = /* @__PURE__ */ new Set(["multi.surbl.org", "multi.uribl.com"]);
|
|
16317
|
+
var DNS_CONCURRENCY = 8;
|
|
16318
|
+
var defaultResolver = {
|
|
16319
|
+
resolve4: (hostname) => dns.resolve4(hostname)
|
|
16320
|
+
};
|
|
16321
|
+
async function runDnsblChecks(ctx, opts = {}) {
|
|
16322
|
+
const cfg = ctx.config.dnsbl ?? {};
|
|
16323
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
16324
|
+
if (!opts.force && intervalHours <= 0) {
|
|
16325
|
+
return { ran: false, reason: "disabled" };
|
|
16326
|
+
}
|
|
16327
|
+
const targets = collectTargets(ctx, cfg);
|
|
16328
|
+
if (targets.domains.length === 0 && targets.ips.length === 0) {
|
|
16329
|
+
return { ran: false, reason: "no_targets" };
|
|
16330
|
+
}
|
|
16331
|
+
const resolver = opts.resolver ?? defaultResolver;
|
|
16332
|
+
const domainLists = cfg.domainLists ?? DEFAULT_DOMAIN_DNSBL_LISTS;
|
|
16333
|
+
const ipLists = cfg.ipLists ?? DEFAULT_IP_DNSBL_LISTS;
|
|
16334
|
+
const pairs = [];
|
|
16335
|
+
for (const d of targets.domains) {
|
|
16336
|
+
for (const l of domainLists) pairs.push({ target: d, targetKind: "domain", list: l });
|
|
16337
|
+
}
|
|
16338
|
+
for (const ip of targets.ips) {
|
|
16339
|
+
for (const l of ipLists) pairs.push({ target: ip, targetKind: "ip", list: l });
|
|
16340
|
+
}
|
|
16341
|
+
const throttleCutoff = opts.force ? null : Date.now() - intervalHours * 60 * 60 * 1e3;
|
|
16342
|
+
let duePairs = pairs;
|
|
16343
|
+
if (throttleCutoff != null) {
|
|
16344
|
+
const existing = await ctx.collections.dnsblChecks.find(
|
|
16345
|
+
{
|
|
16346
|
+
$or: pairs.map((p) => ({ target: p.target, list: p.list.host }))
|
|
16347
|
+
},
|
|
16348
|
+
{ projection: { target: 1, list: 1, runAt: 1 } }
|
|
16349
|
+
).toArray();
|
|
16350
|
+
const fresh = /* @__PURE__ */ new Set();
|
|
16351
|
+
for (const e of existing) {
|
|
16352
|
+
if (new Date(e.runAt).getTime() > throttleCutoff) {
|
|
16353
|
+
fresh.add(`${e.target}|${e.list}`);
|
|
16354
|
+
}
|
|
16355
|
+
}
|
|
16356
|
+
duePairs = pairs.filter((p) => !fresh.has(`${p.target}|${p.list.host}`));
|
|
16357
|
+
if (duePairs.length === 0) {
|
|
16358
|
+
return { ran: false, reason: "not_due", totalChecks: 0, listedCount: 0 };
|
|
16359
|
+
}
|
|
16360
|
+
}
|
|
16361
|
+
let listedCount = 0;
|
|
16362
|
+
async function processPair(p) {
|
|
16363
|
+
const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
|
|
16364
|
+
const lookup = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
|
|
16365
|
+
if (lookup.transient) return;
|
|
16366
|
+
if (lookup.result === "listed") listedCount++;
|
|
16367
|
+
await ctx.collections.dnsblChecks.updateOne(
|
|
16368
|
+
{ target: p.target, list: p.list.host },
|
|
16369
|
+
{
|
|
16370
|
+
$set: {
|
|
16371
|
+
target: p.target,
|
|
16372
|
+
targetKind: p.targetKind,
|
|
16373
|
+
list: p.list.host,
|
|
16374
|
+
listLabel: p.list.label,
|
|
16375
|
+
result: lookup.result,
|
|
16376
|
+
returnCodes: lookup.returnCodes,
|
|
16377
|
+
errorMessage: lookup.errorMessage,
|
|
16378
|
+
runAt: /* @__PURE__ */ new Date()
|
|
16379
|
+
}
|
|
16380
|
+
},
|
|
16381
|
+
{ upsert: true }
|
|
16382
|
+
);
|
|
16383
|
+
}
|
|
16384
|
+
let idx = 0;
|
|
16385
|
+
await Promise.all(
|
|
16386
|
+
Array.from({ length: Math.min(DNS_CONCURRENCY, duePairs.length) }, async () => {
|
|
16387
|
+
while (idx < duePairs.length) {
|
|
16388
|
+
const my = idx++;
|
|
16389
|
+
await processPair(duePairs[my]);
|
|
16390
|
+
}
|
|
16391
|
+
})
|
|
16392
|
+
);
|
|
16393
|
+
return { ran: true, totalChecks: duePairs.length, listedCount };
|
|
16394
|
+
}
|
|
16395
|
+
function collectTargets(ctx, cfg) {
|
|
16396
|
+
const domains = /* @__PURE__ */ new Set();
|
|
16397
|
+
const registry = ctx.config.senderDomains;
|
|
16398
|
+
if (registry) {
|
|
16399
|
+
for (const d of Object.keys(registry)) domains.add(d.toLowerCase());
|
|
16400
|
+
}
|
|
16401
|
+
const fromDomain = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
|
|
16402
|
+
if (fromDomain) domains.add(fromDomain);
|
|
16403
|
+
const txnDomain = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
|
|
16404
|
+
if (txnDomain) domains.add(txnDomain);
|
|
16405
|
+
return {
|
|
16406
|
+
domains: Array.from(domains),
|
|
16407
|
+
ips: (cfg.dedicatedIps ?? []).filter((ip) => net.isIP(ip) !== 0)
|
|
16408
|
+
};
|
|
16409
|
+
}
|
|
16410
|
+
function buildQueryName(target, kind, listHost) {
|
|
16411
|
+
if (kind === "ip") {
|
|
16412
|
+
const v = net.isIP(target);
|
|
16413
|
+
if (v === 4) return `${reverseIPv4(target)}.${listHost}`;
|
|
16414
|
+
if (v === 6) return `${reverseIPv6Nibbles(target)}.${listHost}`;
|
|
16415
|
+
return null;
|
|
16416
|
+
}
|
|
16417
|
+
const domain = REGISTRABLE_ONLY_LISTS.has(listHost) ? registrableDomain(target) : target;
|
|
16418
|
+
return `${domain}.${listHost}`;
|
|
16419
|
+
}
|
|
16420
|
+
var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
|
|
16421
|
+
async function queryDnsbl(resolver, query) {
|
|
16422
|
+
try {
|
|
16423
|
+
const records = await resolver.resolve4(query);
|
|
16424
|
+
return interpretRecords(records);
|
|
16425
|
+
} catch (err) {
|
|
16426
|
+
const code = err?.code;
|
|
16427
|
+
if (code === "ENOTFOUND" || code === "ENODATA") {
|
|
16428
|
+
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
16429
|
+
}
|
|
16430
|
+
if (code && TRANSIENT_DNS_CODES.has(code)) {
|
|
16431
|
+
return {
|
|
16432
|
+
transient: true,
|
|
16433
|
+
result: "error",
|
|
16434
|
+
returnCodes: [],
|
|
16435
|
+
errorMessage: String(err?.message ?? err)
|
|
16436
|
+
};
|
|
16437
|
+
}
|
|
16438
|
+
return {
|
|
16439
|
+
result: "error",
|
|
16440
|
+
returnCodes: [],
|
|
16441
|
+
errorMessage: String(err?.message ?? err)
|
|
16442
|
+
};
|
|
16443
|
+
}
|
|
16444
|
+
}
|
|
16445
|
+
function interpretRecords(records) {
|
|
16446
|
+
if (!records || records.length === 0) {
|
|
16447
|
+
return { result: "clean", returnCodes: [], errorMessage: null };
|
|
16448
|
+
}
|
|
16449
|
+
const errorish = records.filter((r) => r.startsWith("127.255.255."));
|
|
16450
|
+
if (errorish.length === records.length) {
|
|
16451
|
+
return {
|
|
16452
|
+
result: "error",
|
|
16453
|
+
returnCodes: records,
|
|
16454
|
+
errorMessage: `list returned reserved code(s): ${records.join(", ")}`
|
|
16455
|
+
};
|
|
15935
16456
|
}
|
|
16457
|
+
const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
|
|
16458
|
+
if (listed.length > 0) {
|
|
16459
|
+
return { result: "listed", returnCodes: records, errorMessage: null };
|
|
16460
|
+
}
|
|
16461
|
+
return { result: "clean", returnCodes: records, errorMessage: null };
|
|
16462
|
+
}
|
|
16463
|
+
function reverseIPv4(ip) {
|
|
16464
|
+
return ip.split(".").reverse().join(".");
|
|
16465
|
+
}
|
|
16466
|
+
function reverseIPv6Nibbles(ip) {
|
|
16467
|
+
const v4Match = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(ip);
|
|
16468
|
+
let normalized = ip;
|
|
16469
|
+
if (v4Match) {
|
|
16470
|
+
const octets = v4Match[1].split(".").map((o) => Number(o));
|
|
16471
|
+
if (octets.length === 4 && octets.every((o) => o >= 0 && o <= 255)) {
|
|
16472
|
+
const hi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
|
|
16473
|
+
const lo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
|
|
16474
|
+
normalized = ip.slice(0, v4Match.index) + ":" + hi + ":" + lo;
|
|
16475
|
+
}
|
|
16476
|
+
}
|
|
16477
|
+
const sides = normalized.split("::");
|
|
16478
|
+
let groups;
|
|
16479
|
+
if (sides.length === 1) {
|
|
16480
|
+
groups = sides[0].split(":");
|
|
16481
|
+
} else {
|
|
16482
|
+
const left = sides[0].split(":").filter(Boolean);
|
|
16483
|
+
const right = sides[1].split(":").filter(Boolean);
|
|
16484
|
+
const missing = 8 - left.length - right.length;
|
|
16485
|
+
groups = [...left, ...Array(missing).fill("0"), ...right];
|
|
16486
|
+
}
|
|
16487
|
+
if (groups.length !== 8) {
|
|
16488
|
+
throw new Error(`unexpected IPv6 group count for ${ip}: ${groups.length}`);
|
|
16489
|
+
}
|
|
16490
|
+
const padded = groups.map((g) => g.toLowerCase().padStart(4, "0")).join("");
|
|
16491
|
+
return padded.split("").reverse().join(".");
|
|
16492
|
+
}
|
|
16493
|
+
function registrableDomain(domain) {
|
|
16494
|
+
const parsed = psl.parse(domain);
|
|
16495
|
+
if ("domain" in parsed && parsed.domain) return parsed.domain;
|
|
16496
|
+
return domain;
|
|
15936
16497
|
}
|
|
15937
16498
|
|
|
15938
|
-
// src/server/runner/
|
|
15939
|
-
var
|
|
15940
|
-
|
|
16499
|
+
// src/server/runner/postmaster.ts
|
|
16500
|
+
var OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
16501
|
+
var POSTMASTER_BASE = "https://gmailpostmastertools.googleapis.com/v1";
|
|
16502
|
+
var FETCH_TIMEOUT_MS = 15e3;
|
|
16503
|
+
async function fetchWithTimeout(fetcher, url, init = {}, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
16504
|
+
const ctl = new AbortController();
|
|
16505
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
16506
|
+
try {
|
|
16507
|
+
return await fetcher(url, { ...init, signal: ctl.signal });
|
|
16508
|
+
} finally {
|
|
16509
|
+
clearTimeout(t);
|
|
16510
|
+
}
|
|
16511
|
+
}
|
|
16512
|
+
function createPostmasterClient(cfg, fetcher = globalThis.fetch) {
|
|
16513
|
+
let cached = null;
|
|
16514
|
+
async function getAccessToken() {
|
|
16515
|
+
if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1e3) {
|
|
16516
|
+
return cached.accessToken;
|
|
16517
|
+
}
|
|
16518
|
+
const body = new URLSearchParams({
|
|
16519
|
+
client_id: cfg.clientId,
|
|
16520
|
+
client_secret: cfg.clientSecret,
|
|
16521
|
+
refresh_token: cfg.refreshToken,
|
|
16522
|
+
grant_type: "refresh_token"
|
|
16523
|
+
});
|
|
16524
|
+
const res = await fetchWithTimeout(fetcher, OAUTH_TOKEN_URL, {
|
|
16525
|
+
method: "POST",
|
|
16526
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
16527
|
+
body: body.toString()
|
|
16528
|
+
});
|
|
16529
|
+
if (!res.ok) {
|
|
16530
|
+
throw new Error(`Postmaster OAuth token refresh failed: ${res.status} ${await safeText(res)}`);
|
|
16531
|
+
}
|
|
16532
|
+
const data = await res.json();
|
|
16533
|
+
cached = {
|
|
16534
|
+
accessToken: data.access_token,
|
|
16535
|
+
expiresAt: Date.now() + data.expires_in * 1e3
|
|
16536
|
+
};
|
|
16537
|
+
return cached.accessToken;
|
|
16538
|
+
}
|
|
16539
|
+
async function authedGet(path) {
|
|
16540
|
+
const token = await getAccessToken();
|
|
16541
|
+
const res = await fetchWithTimeout(fetcher, `${POSTMASTER_BASE}${path}`, {
|
|
16542
|
+
method: "GET",
|
|
16543
|
+
headers: { authorization: `Bearer ${token}` }
|
|
16544
|
+
});
|
|
16545
|
+
if (!res.ok) {
|
|
16546
|
+
throw new Error(`Postmaster GET ${path} failed: ${res.status} ${await safeText(res)}`);
|
|
16547
|
+
}
|
|
16548
|
+
return await res.json();
|
|
16549
|
+
}
|
|
16550
|
+
return {
|
|
16551
|
+
async listDomains() {
|
|
16552
|
+
const data = await authedGet("/domains");
|
|
16553
|
+
return data.domains ?? [];
|
|
16554
|
+
},
|
|
16555
|
+
async getLatestTrafficStats(domain) {
|
|
16556
|
+
const data = await authedGet(
|
|
16557
|
+
`/domains/${encodeURIComponent(domain)}/trafficStats?pageSize=1`
|
|
16558
|
+
);
|
|
16559
|
+
return data.trafficStats?.[0] ?? null;
|
|
16560
|
+
}
|
|
16561
|
+
};
|
|
16562
|
+
}
|
|
16563
|
+
async function safeText(res) {
|
|
16564
|
+
try {
|
|
16565
|
+
return await res.text();
|
|
16566
|
+
} catch {
|
|
16567
|
+
return "<no body>";
|
|
16568
|
+
}
|
|
16569
|
+
}
|
|
16570
|
+
async function runPostmasterPull(ctx, opts = {}) {
|
|
16571
|
+
const cfg = ctx.config.postmaster;
|
|
16572
|
+
if (!cfg) return { ran: false, reason: "not_configured" };
|
|
16573
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
16574
|
+
if (!opts.force && intervalHours <= 0) {
|
|
16575
|
+
return { ran: false, reason: "disabled" };
|
|
16576
|
+
}
|
|
16577
|
+
const client = opts.client ?? createPostmasterClient(cfg, opts.fetcher);
|
|
16578
|
+
const allDomains = resolveDomains(ctx, cfg);
|
|
16579
|
+
if (allDomains.length === 0) {
|
|
16580
|
+
return { ran: false, reason: "no_domains" };
|
|
16581
|
+
}
|
|
16582
|
+
let domains = allDomains;
|
|
16583
|
+
if (!opts.force) {
|
|
16584
|
+
const cutoff = Date.now() - intervalHours * 60 * 60 * 1e3;
|
|
16585
|
+
const latest = await ctx.collections.postmasterSnapshots.find({ domain: { $in: allDomains } }, { projection: { domain: 1, fetchedAt: 1 } }).sort({ fetchedAt: -1 }).limit(allDomains.length * 8).toArray();
|
|
16586
|
+
const lastByDomain = /* @__PURE__ */ new Map();
|
|
16587
|
+
for (const s of latest) {
|
|
16588
|
+
const ts = new Date(s.fetchedAt).getTime();
|
|
16589
|
+
const cur = lastByDomain.get(s.domain) ?? 0;
|
|
16590
|
+
if (ts > cur) lastByDomain.set(s.domain, ts);
|
|
16591
|
+
}
|
|
16592
|
+
domains = allDomains.filter((d) => (lastByDomain.get(d) ?? 0) < cutoff);
|
|
16593
|
+
if (domains.length === 0) {
|
|
16594
|
+
return { ran: false, reason: "not_due" };
|
|
16595
|
+
}
|
|
16596
|
+
}
|
|
16597
|
+
const fetches = await Promise.all(
|
|
16598
|
+
domains.map(async (domain) => {
|
|
16599
|
+
try {
|
|
16600
|
+
const stat = await client.getLatestTrafficStats(domain);
|
|
16601
|
+
return { domain, stat, error: null };
|
|
16602
|
+
} catch (err) {
|
|
16603
|
+
console.error(`mailery: postmaster fetch failed for ${domain}`, err);
|
|
16604
|
+
return { domain, stat: null, error: err };
|
|
16605
|
+
}
|
|
16606
|
+
})
|
|
16607
|
+
);
|
|
16608
|
+
let fetched = 0;
|
|
16609
|
+
const trippedDomains = [];
|
|
16610
|
+
for (const { domain, stat } of fetches) {
|
|
16611
|
+
if (!stat) continue;
|
|
16612
|
+
const snapshot = toSnapshot(domain, stat);
|
|
16613
|
+
if (!snapshot) continue;
|
|
16614
|
+
fetched++;
|
|
16615
|
+
await ctx.collections.postmasterSnapshots.updateOne(
|
|
16616
|
+
{ domain: snapshot.domain, date: snapshot.date },
|
|
16617
|
+
{ $set: snapshot },
|
|
16618
|
+
{ upsert: true }
|
|
16619
|
+
);
|
|
16620
|
+
if (snapshot.domainReputation === "BAD") {
|
|
16621
|
+
const kindsToTrip = kindsForDomain(ctx, domain);
|
|
16622
|
+
for (const kind of kindsToTrip) {
|
|
16623
|
+
const tripped = await tripBucket(ctx, domain, kind, snapshot);
|
|
16624
|
+
if (tripped) trippedDomains.push(`${domain}|${kind}`);
|
|
16625
|
+
}
|
|
16626
|
+
}
|
|
16627
|
+
}
|
|
16628
|
+
return { ran: true, fetched, trippedDomains };
|
|
16629
|
+
}
|
|
16630
|
+
function kindsForDomain(ctx, domain) {
|
|
16631
|
+
const registry = ctx.config.senderDomains ?? {};
|
|
16632
|
+
const entry = registry[domain.toLowerCase()];
|
|
16633
|
+
if (!entry) return [];
|
|
16634
|
+
if (entry.kind === "both") return ["marketing", "transactional"];
|
|
16635
|
+
return [entry.kind];
|
|
16636
|
+
}
|
|
16637
|
+
function resolveDomains(ctx, cfg) {
|
|
16638
|
+
if (cfg.domains && cfg.domains.length > 0) {
|
|
16639
|
+
return cfg.domains.map((d) => d.toLowerCase());
|
|
16640
|
+
}
|
|
16641
|
+
const out = /* @__PURE__ */ new Set();
|
|
16642
|
+
const registry = ctx.config.senderDomains;
|
|
16643
|
+
if (registry) {
|
|
16644
|
+
for (const d of Object.keys(registry)) out.add(d.toLowerCase());
|
|
16645
|
+
}
|
|
16646
|
+
const f = ctx.config.fromDefaults?.email ? extractDomain(ctx.config.fromDefaults.email) : null;
|
|
16647
|
+
if (f) out.add(f);
|
|
16648
|
+
const t = ctx.config.transactionalFromDefaults?.email ? extractDomain(ctx.config.transactionalFromDefaults.email) : null;
|
|
16649
|
+
if (t) out.add(t);
|
|
16650
|
+
return Array.from(out);
|
|
16651
|
+
}
|
|
16652
|
+
function toSnapshot(domain, stat) {
|
|
16653
|
+
const m = /\/trafficStats\/(\d{8})$/.exec(stat.name ?? "");
|
|
16654
|
+
if (!m) {
|
|
16655
|
+
console.error(`mailery: postmaster snapshot for ${domain} has unrecognized name "${stat.name}" \u2014 skipping`);
|
|
16656
|
+
return null;
|
|
16657
|
+
}
|
|
16658
|
+
const yyyymmdd = m[1];
|
|
16659
|
+
const date = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
|
16660
|
+
return {
|
|
16661
|
+
domain,
|
|
16662
|
+
date,
|
|
16663
|
+
domainReputation: stat.domainReputation ?? null,
|
|
16664
|
+
ipReputations: stat.ipReputations ? stat.ipReputations.map((r) => ({
|
|
16665
|
+
reputation: r.reputation,
|
|
16666
|
+
ipCount: typeof r.ipCount === "string" ? Number(r.ipCount) : r.ipCount
|
|
16667
|
+
})) : null,
|
|
16668
|
+
userReportedSpamRatio: stat.userReportedSpamRatio ?? null,
|
|
16669
|
+
spfSuccessRatio: stat.spfSuccessRatio ?? null,
|
|
16670
|
+
dkimSuccessRatio: stat.dkimSuccessRatio ?? null,
|
|
16671
|
+
dmarcSuccessRatio: stat.dmarcSuccessRatio ?? null,
|
|
16672
|
+
outboundEncryptionRatio: stat.outboundEncryptionRatio ?? null,
|
|
16673
|
+
inboundEncryptionRatio: stat.inboundEncryptionRatio ?? null,
|
|
16674
|
+
deliveryErrors: stat.deliveryErrors ?? null,
|
|
16675
|
+
spammyFeedbackLoops: stat.spammyFeedbackLoops ?? null,
|
|
16676
|
+
fetchedAt: /* @__PURE__ */ new Date()
|
|
16677
|
+
};
|
|
16678
|
+
}
|
|
16679
|
+
async function tripBucket(ctx, domain, kind, snapshot) {
|
|
16680
|
+
const id = healthBucketId(domain, kind);
|
|
16681
|
+
const reason = `Postmaster Tools reports ${domain} reputation = BAD on ${snapshot.date}`;
|
|
16682
|
+
const prior = await ctx.collections.health.findOne({ _id: id });
|
|
16683
|
+
if (prior?.status === "tripped") {
|
|
16684
|
+
return false;
|
|
16685
|
+
}
|
|
15941
16686
|
await ctx.collections.health.updateOne(
|
|
15942
|
-
{ _id:
|
|
16687
|
+
{ _id: id },
|
|
15943
16688
|
{
|
|
15944
|
-
$set: {
|
|
16689
|
+
$set: {
|
|
16690
|
+
status: "tripped",
|
|
16691
|
+
trippedAt: /* @__PURE__ */ new Date(),
|
|
16692
|
+
trippedReason: reason,
|
|
16693
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
16694
|
+
},
|
|
15945
16695
|
$setOnInsert: {
|
|
15946
|
-
_id:
|
|
16696
|
+
_id: id,
|
|
16697
|
+
senderDomain: domain,
|
|
16698
|
+
kind,
|
|
15947
16699
|
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15948
16700
|
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
15949
|
-
status: "healthy",
|
|
15950
|
-
trippedAt: null,
|
|
15951
|
-
trippedReason: null,
|
|
15952
16701
|
manuallyResumedAt: null,
|
|
15953
16702
|
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
15954
16703
|
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
15955
16704
|
}
|
|
15956
16705
|
},
|
|
15957
16706
|
{ upsert: true }
|
|
15958
|
-
)
|
|
15959
|
-
|
|
16707
|
+
);
|
|
16708
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
16709
|
+
try {
|
|
16710
|
+
await ctx.config.onCircuitBreakerTrip({ reason, rates: { userReportedSpamRatio: snapshot.userReportedSpamRatio ?? 0 } });
|
|
16711
|
+
} catch {
|
|
16712
|
+
}
|
|
16713
|
+
}
|
|
16714
|
+
return true;
|
|
16715
|
+
}
|
|
16716
|
+
|
|
16717
|
+
// src/server/runner/snds.ts
|
|
16718
|
+
var SNDS_DATA_URL = "https://postmaster.live.com/snds/data.aspx";
|
|
16719
|
+
var FETCH_TIMEOUT_MS2 = 3e4;
|
|
16720
|
+
async function runSndsPull(ctx, opts = {}) {
|
|
16721
|
+
const cfg = ctx.config.snds;
|
|
16722
|
+
if (!cfg?.accessKey) return { ran: false, reason: "not_configured" };
|
|
16723
|
+
const intervalHours = cfg.intervalHours ?? 24;
|
|
16724
|
+
if (!opts.force && intervalHours <= 0) return { ran: false, reason: "disabled" };
|
|
16725
|
+
if (!opts.force) {
|
|
16726
|
+
const latest = await ctx.collections.sndsSnapshots.find({}).sort({ fetchedAt: -1 }).limit(1).toArray();
|
|
16727
|
+
if (latest[0]) {
|
|
16728
|
+
const ageMs = Date.now() - new Date(latest[0].fetchedAt).getTime();
|
|
16729
|
+
if (ageMs < intervalHours * 60 * 60 * 1e3) {
|
|
16730
|
+
return { ran: false, reason: "not_due" };
|
|
16731
|
+
}
|
|
16732
|
+
}
|
|
16733
|
+
}
|
|
16734
|
+
const fetcher = opts.fetcher ?? globalThis.fetch;
|
|
16735
|
+
const url = `${SNDS_DATA_URL}?key=${encodeURIComponent(cfg.accessKey)}`;
|
|
16736
|
+
const ctl = new AbortController();
|
|
16737
|
+
const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS2);
|
|
16738
|
+
let res;
|
|
16739
|
+
try {
|
|
16740
|
+
res = await fetcher(url, { method: "GET", signal: ctl.signal });
|
|
16741
|
+
} catch (err) {
|
|
16742
|
+
throw new Error(`SNDS data fetch failed: ${redactKey(String(err?.message ?? err), cfg.accessKey)}`);
|
|
16743
|
+
} finally {
|
|
16744
|
+
clearTimeout(t);
|
|
16745
|
+
}
|
|
16746
|
+
if (!res.ok) {
|
|
16747
|
+
throw new Error(`SNDS data fetch failed: ${res.status} ${redactKey(await safeText2(res), cfg.accessKey)}`);
|
|
16748
|
+
}
|
|
16749
|
+
const csv = await res.text();
|
|
16750
|
+
const rows = parseSndsCsv(csv);
|
|
16751
|
+
const ipFilter = cfg.ips?.length ? new Set(cfg.ips) : null;
|
|
16752
|
+
const now = /* @__PURE__ */ new Date();
|
|
16753
|
+
const ops = rows.filter((row) => !ipFilter || ipFilter.has(row.ip)).map((row) => ({
|
|
16754
|
+
updateOne: {
|
|
16755
|
+
filter: { ip: row.ip, activityStart: row.activityStart },
|
|
16756
|
+
update: { $set: { ...row, fetchedAt: now } },
|
|
16757
|
+
upsert: true
|
|
16758
|
+
}
|
|
16759
|
+
}));
|
|
16760
|
+
let persisted = 0;
|
|
16761
|
+
if (ops.length > 0) {
|
|
16762
|
+
const r = await ctx.collections.sndsSnapshots.bulkWrite(ops, { ordered: false });
|
|
16763
|
+
persisted = (r.modifiedCount ?? 0) + (r.upsertedCount ?? 0);
|
|
16764
|
+
}
|
|
16765
|
+
return { ran: true, rowsParsed: rows.length, rowsPersisted: persisted };
|
|
16766
|
+
}
|
|
16767
|
+
function redactKey(s, key) {
|
|
16768
|
+
if (!key) return s;
|
|
16769
|
+
return s.split(key).join("<redacted>").split(encodeURIComponent(key)).join("<redacted>");
|
|
16770
|
+
}
|
|
16771
|
+
async function safeText2(res) {
|
|
16772
|
+
try {
|
|
16773
|
+
return await res.text();
|
|
16774
|
+
} catch {
|
|
16775
|
+
return "<no body>";
|
|
16776
|
+
}
|
|
16777
|
+
}
|
|
16778
|
+
function parseSndsCsv(csv) {
|
|
16779
|
+
const out = [];
|
|
16780
|
+
for (const raw of csv.split(/\r?\n/)) {
|
|
16781
|
+
if (!raw.trim()) continue;
|
|
16782
|
+
const fields = splitCsvRow(raw);
|
|
16783
|
+
if (fields.length < 7) continue;
|
|
16784
|
+
const parsed = parseRow(fields);
|
|
16785
|
+
if (parsed) out.push(parsed);
|
|
16786
|
+
}
|
|
16787
|
+
return out;
|
|
16788
|
+
}
|
|
16789
|
+
function splitCsvRow(row) {
|
|
16790
|
+
const out = [];
|
|
16791
|
+
let cur = "";
|
|
16792
|
+
let inQuotes = false;
|
|
16793
|
+
for (let i = 0; i < row.length; i++) {
|
|
16794
|
+
const ch = row[i];
|
|
16795
|
+
if (inQuotes) {
|
|
16796
|
+
if (ch === '"') {
|
|
16797
|
+
if (row[i + 1] === '"') {
|
|
16798
|
+
cur += '"';
|
|
16799
|
+
i++;
|
|
16800
|
+
} else {
|
|
16801
|
+
inQuotes = false;
|
|
16802
|
+
}
|
|
16803
|
+
} else {
|
|
16804
|
+
cur += ch;
|
|
16805
|
+
}
|
|
16806
|
+
} else if (ch === '"') {
|
|
16807
|
+
inQuotes = true;
|
|
16808
|
+
} else if (ch === ",") {
|
|
16809
|
+
out.push(cur);
|
|
16810
|
+
cur = "";
|
|
16811
|
+
} else {
|
|
16812
|
+
cur += ch;
|
|
16813
|
+
}
|
|
16814
|
+
}
|
|
16815
|
+
out.push(cur);
|
|
16816
|
+
return out.map((f) => f.trim());
|
|
16817
|
+
}
|
|
16818
|
+
function parseRow(fields) {
|
|
16819
|
+
const [
|
|
16820
|
+
ip,
|
|
16821
|
+
activityStart,
|
|
16822
|
+
activityEnd,
|
|
16823
|
+
rcptCommands,
|
|
16824
|
+
dataCommands,
|
|
16825
|
+
messageRecipients,
|
|
16826
|
+
filterResult,
|
|
16827
|
+
complaintRate,
|
|
16828
|
+
trapMessageCount,
|
|
16829
|
+
sampleHelo,
|
|
16830
|
+
sampleMailFrom
|
|
16831
|
+
] = fields;
|
|
16832
|
+
const start = parseSndsDate(activityStart ?? "");
|
|
16833
|
+
const end = parseSndsDate(activityEnd ?? "");
|
|
16834
|
+
if (!ip || !start || !end) return null;
|
|
16835
|
+
return {
|
|
16836
|
+
ip,
|
|
16837
|
+
activityStart: start,
|
|
16838
|
+
activityEnd: end,
|
|
16839
|
+
rcptCommands: toInt(rcptCommands),
|
|
16840
|
+
dataCommands: toInt(dataCommands),
|
|
16841
|
+
messageRecipients: toInt(messageRecipients),
|
|
16842
|
+
filterResult: normalizeFilter(filterResult ?? ""),
|
|
16843
|
+
complaintRate: parseComplaintRate(complaintRate ?? ""),
|
|
16844
|
+
trapMessageCount: toInt(trapMessageCount),
|
|
16845
|
+
sampleHelo: sampleHelo ? sampleHelo : null,
|
|
16846
|
+
sampleMailFrom: sampleMailFrom ? sampleMailFrom : null
|
|
16847
|
+
};
|
|
16848
|
+
}
|
|
16849
|
+
function toInt(s) {
|
|
16850
|
+
if (!s) return 0;
|
|
16851
|
+
const n = Number(s.replace(/[^\d-]/g, ""));
|
|
16852
|
+
return Number.isFinite(n) ? n : 0;
|
|
16853
|
+
}
|
|
16854
|
+
function normalizeFilter(s) {
|
|
16855
|
+
const u = s.toUpperCase().trim();
|
|
16856
|
+
if (u === "GREEN" || u === "YELLOW" || u === "RED") return u;
|
|
16857
|
+
return "UNKNOWN";
|
|
16858
|
+
}
|
|
16859
|
+
function parseComplaintRate(s) {
|
|
16860
|
+
const trimmed = s.trim();
|
|
16861
|
+
if (!trimmed || trimmed === "-" || /n\/?a/i.test(trimmed)) return null;
|
|
16862
|
+
const cleaned = trimmed.replace(/%/g, "");
|
|
16863
|
+
let m = /^<\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16864
|
+
if (m) return Number(m[1]) / 100;
|
|
16865
|
+
m = /^>\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16866
|
+
if (m) return Number(m[1]) / 100;
|
|
16867
|
+
m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16868
|
+
if (m) return Number(m[2]) / 100;
|
|
16869
|
+
m = /^(\d+(?:\.\d+)?)$/.exec(cleaned);
|
|
16870
|
+
if (m) return Number(m[1]) / 100;
|
|
16871
|
+
return null;
|
|
16872
|
+
}
|
|
16873
|
+
function parseSndsDate(s) {
|
|
16874
|
+
const trimmed = s.trim();
|
|
16875
|
+
if (!trimmed) return null;
|
|
16876
|
+
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i.exec(trimmed);
|
|
16877
|
+
if (m) {
|
|
16878
|
+
const month = Number(m[1]) - 1;
|
|
16879
|
+
const day = Number(m[2]);
|
|
16880
|
+
const year = Number(m[3]);
|
|
16881
|
+
let hour = Number(m[4]);
|
|
16882
|
+
const minute = Number(m[5]);
|
|
16883
|
+
const second = Number(m[6] ?? 0);
|
|
16884
|
+
const meridiem = m[7]?.toUpperCase();
|
|
16885
|
+
if (meridiem === "PM" && hour < 12) hour += 12;
|
|
16886
|
+
if (meridiem === "AM" && hour === 12) hour = 0;
|
|
16887
|
+
const wallMs = Date.UTC(year, month, day, hour, minute, second);
|
|
16888
|
+
if (!Number.isFinite(wallMs)) return null;
|
|
16889
|
+
const offsetHours = isUsPacificDst(year, month, day) ? 7 : 8;
|
|
16890
|
+
return new Date(wallMs + offsetHours * 60 * 60 * 1e3);
|
|
16891
|
+
}
|
|
16892
|
+
const d = new Date(trimmed);
|
|
16893
|
+
if (!isNaN(d.getTime())) return d;
|
|
16894
|
+
return null;
|
|
16895
|
+
}
|
|
16896
|
+
function isUsPacificDst(year, month0, day) {
|
|
16897
|
+
if (month0 > 2 && month0 < 10) return true;
|
|
16898
|
+
if (month0 < 2 || month0 > 10) return false;
|
|
16899
|
+
if (month0 === 2) {
|
|
16900
|
+
const dst2 = nthSundayOfMonth(year, 2, 2);
|
|
16901
|
+
return day >= dst2;
|
|
16902
|
+
}
|
|
16903
|
+
const dst = nthSundayOfMonth(year, 10, 1);
|
|
16904
|
+
return day < dst;
|
|
16905
|
+
}
|
|
16906
|
+
function nthSundayOfMonth(year, month0, n) {
|
|
16907
|
+
const first = new Date(Date.UTC(year, month0, 1));
|
|
16908
|
+
const dayOfWeek = first.getUTCDay();
|
|
16909
|
+
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
16910
|
+
return firstSunday + (n - 1) * 7;
|
|
16911
|
+
}
|
|
16912
|
+
var _lastPruneAt = 0;
|
|
16913
|
+
var PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
|
|
16914
|
+
async function pruneDmarcFailures(ctx, opts = {}) {
|
|
16915
|
+
const cfg = ctx.config.dmarc;
|
|
16916
|
+
const days = cfg?.retentionDays ?? 90;
|
|
16917
|
+
if (days <= 0) return 0;
|
|
16918
|
+
if (!opts.force && Date.now() - _lastPruneAt < PRUNE_INTERVAL_MS) return 0;
|
|
16919
|
+
_lastPruneAt = Date.now();
|
|
16920
|
+
const cutoff = new Date(Date.now() - days * 864e5);
|
|
16921
|
+
const r = await ctx.collections.dmarcFailures.deleteMany({ receivedAt: { $lt: cutoff } });
|
|
16922
|
+
return r.deletedCount ?? 0;
|
|
16923
|
+
}
|
|
16924
|
+
|
|
16925
|
+
// src/server/runner/tick.ts
|
|
16926
|
+
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16927
|
+
async function runTick(ctx) {
|
|
16928
|
+
try {
|
|
16929
|
+
await ctx.collections.health.updateOne(
|
|
16930
|
+
{ _id: HEALTH_AGG_ID },
|
|
16931
|
+
{
|
|
16932
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() },
|
|
16933
|
+
$setOnInsert: {
|
|
16934
|
+
_id: HEALTH_AGG_ID,
|
|
16935
|
+
senderDomain: null,
|
|
16936
|
+
kind: null,
|
|
16937
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
16938
|
+
windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
|
|
16939
|
+
status: "healthy",
|
|
16940
|
+
trippedAt: null,
|
|
16941
|
+
trippedReason: null,
|
|
16942
|
+
manuallyResumedAt: null,
|
|
16943
|
+
counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
|
|
16944
|
+
rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
|
|
16945
|
+
}
|
|
16946
|
+
},
|
|
16947
|
+
{ upsert: true }
|
|
16948
|
+
);
|
|
16949
|
+
} catch (err) {
|
|
16950
|
+
console.error("mailery: heartbeat write failed", err);
|
|
16951
|
+
}
|
|
15960
16952
|
await processNewlyFiredEventTriggers(ctx).catch((err) => {
|
|
15961
16953
|
console.error("mailery: triggers scan failed", err);
|
|
15962
16954
|
});
|
|
@@ -15978,6 +16970,20 @@ async function runTick(ctx) {
|
|
|
15978
16970
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
15979
16971
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
15980
16972
|
});
|
|
16973
|
+
await Promise.all([
|
|
16974
|
+
runDnsblChecks(ctx).catch((err) => {
|
|
16975
|
+
console.error("mailery: dnsbl checks failed", err);
|
|
16976
|
+
}),
|
|
16977
|
+
runPostmasterPull(ctx).catch((err) => {
|
|
16978
|
+
console.error("mailery: postmaster pull failed", err);
|
|
16979
|
+
}),
|
|
16980
|
+
runSndsPull(ctx).catch((err) => {
|
|
16981
|
+
console.error("mailery: snds pull failed", err);
|
|
16982
|
+
}),
|
|
16983
|
+
pruneDmarcFailures(ctx).catch((err) => {
|
|
16984
|
+
console.error("mailery: dmarc prune failed", err);
|
|
16985
|
+
})
|
|
16986
|
+
]);
|
|
15981
16987
|
}
|
|
15982
16988
|
async function sweepStrandedSends(ctx) {
|
|
15983
16989
|
const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
|
|
@@ -16035,6 +17041,10 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
16035
17041
|
}
|
|
16036
17042
|
|
|
16037
17043
|
// src/server/runner/webhook.ts
|
|
17044
|
+
function dimsFromSend(send) {
|
|
17045
|
+
if (!send) return null;
|
|
17046
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
17047
|
+
}
|
|
16038
17048
|
async function applyWebhookEvent(event, ctx) {
|
|
16039
17049
|
const send = await ctx.collections.sends.findOne(
|
|
16040
17050
|
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
@@ -16048,7 +17058,7 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
16048
17058
|
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
16049
17059
|
);
|
|
16050
17060
|
}
|
|
16051
|
-
await recordHealthCounter(ctx, "delivered");
|
|
17061
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
16052
17062
|
break;
|
|
16053
17063
|
case "open":
|
|
16054
17064
|
if (send) {
|
|
@@ -16103,8 +17113,11 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
16103
17113
|
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16104
17114
|
);
|
|
16105
17115
|
}
|
|
16106
|
-
|
|
16107
|
-
|
|
17116
|
+
{
|
|
17117
|
+
const dims = dimsFromSend(send);
|
|
17118
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
17119
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
17120
|
+
}
|
|
16108
17121
|
break;
|
|
16109
17122
|
}
|
|
16110
17123
|
case "complaint":
|
|
@@ -16120,7 +17133,7 @@ async function applyWebhookEvent(event, ctx) {
|
|
|
16120
17133
|
{ emailAtSubscribe: event.email },
|
|
16121
17134
|
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16122
17135
|
);
|
|
16123
|
-
await recordHealthCounter(ctx, "complained");
|
|
17136
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
16124
17137
|
break;
|
|
16125
17138
|
case "unsubscribe":
|
|
16126
17139
|
if (send) {
|
|
@@ -16186,10 +17199,12 @@ var Mailer = class _Mailer {
|
|
|
16186
17199
|
db: this.db,
|
|
16187
17200
|
collections: this.collections,
|
|
16188
17201
|
adapter: this.adapter,
|
|
17202
|
+
varsAdapter: this.config.varsAdapter,
|
|
16189
17203
|
providers: this.providers,
|
|
16190
17204
|
queues: this.queues,
|
|
16191
17205
|
config: this.config,
|
|
16192
|
-
handlebarsHelpers: this.config.handlebarsHelpers
|
|
17206
|
+
handlebarsHelpers: this.config.handlebarsHelpers,
|
|
17207
|
+
audit: (entry) => this.audit(entry)
|
|
16193
17208
|
};
|
|
16194
17209
|
}
|
|
16195
17210
|
/**
|
|
@@ -16264,6 +17279,10 @@ var Mailer = class _Mailer {
|
|
|
16264
17279
|
if (!config.providers[config.defaultProvider]) {
|
|
16265
17280
|
throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
|
|
16266
17281
|
}
|
|
17282
|
+
if (config.varsAdapter) {
|
|
17283
|
+
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
17284
|
+
assertNoReservedVarKeys2(config.varsAdapter);
|
|
17285
|
+
}
|
|
16267
17286
|
const collections = getCollections(config.db, config.collectionPrefix);
|
|
16268
17287
|
await ensureIndexes(config.db, config.collectionPrefix);
|
|
16269
17288
|
const queueDriver = await createQueueDriver(config.queue, config.db);
|
|
@@ -16479,6 +17498,72 @@ var Mailer = class _Mailer {
|
|
|
16479
17498
|
await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
|
|
16480
17499
|
}
|
|
16481
17500
|
}
|
|
17501
|
+
// -------------------------------------------------------------------------
|
|
17502
|
+
// Flow abort
|
|
17503
|
+
// -------------------------------------------------------------------------
|
|
17504
|
+
/**
|
|
17505
|
+
* Abort every active run of one flow for a contact, immediately. Runs parked
|
|
17506
|
+
* in a `wait` exit too — their delayed wake-up jobs find the run exited and
|
|
17507
|
+
* no-op. Also cancels any of the flow's emails still sitting in the send
|
|
17508
|
+
* queue for this contact (queued or awaiting retry), so an abort means no
|
|
17509
|
+
* further mail, not just no further steps.
|
|
17510
|
+
*
|
|
17511
|
+
* No-op (returns zero counts) when nothing is active. Safe to call from the
|
|
17512
|
+
* same handler that processes the business event ("user upgraded").
|
|
17513
|
+
*/
|
|
17514
|
+
async abortFlow(flowSlug, externalId, opts = {}) {
|
|
17515
|
+
const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
|
|
17516
|
+
const flow = await this.collections.flows.findOne(
|
|
17517
|
+
{ slug: parsed.flowSlug },
|
|
17518
|
+
{ projection: { _id: 1 } }
|
|
17519
|
+
);
|
|
17520
|
+
if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
|
|
17521
|
+
const result = await this.abortActiveRuns(
|
|
17522
|
+
{ externalId: parsed.externalId, flowId: flow._id },
|
|
17523
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
17524
|
+
);
|
|
17525
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
17526
|
+
await this.audit({
|
|
17527
|
+
actor: "host",
|
|
17528
|
+
action: "flow.abort",
|
|
17529
|
+
resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
|
|
17530
|
+
diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
17531
|
+
});
|
|
17532
|
+
}
|
|
17533
|
+
return result;
|
|
17534
|
+
}
|
|
17535
|
+
/**
|
|
17536
|
+
* Abort every active flow run for a contact across all flows. Same semantics
|
|
17537
|
+
* as `abortFlow` — for "stop everything" events (account deleted, churned).
|
|
17538
|
+
*/
|
|
17539
|
+
async abortAllFlows(externalId, opts = {}) {
|
|
17540
|
+
const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
|
|
17541
|
+
const result = await this.abortActiveRuns(
|
|
17542
|
+
{ externalId: parsed.externalId },
|
|
17543
|
+
parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
|
|
17544
|
+
);
|
|
17545
|
+
if (result.abortedRuns > 0 || result.cancelledSends > 0) {
|
|
17546
|
+
await this.audit({
|
|
17547
|
+
actor: "host",
|
|
17548
|
+
action: "flow.abort_all",
|
|
17549
|
+
resource: { collection: "mailer_flow_runs" },
|
|
17550
|
+
diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
|
|
17551
|
+
});
|
|
17552
|
+
}
|
|
17553
|
+
return result;
|
|
17554
|
+
}
|
|
17555
|
+
async abortActiveRuns(filter, exitReason) {
|
|
17556
|
+
const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
|
|
17557
|
+
if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
|
|
17558
|
+
for (const run of runs) {
|
|
17559
|
+
await exitFlowRun(run, exitReason, this.runnerContext);
|
|
17560
|
+
}
|
|
17561
|
+
const cancelled = await this.collections.sends.updateMany(
|
|
17562
|
+
{ flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
|
|
17563
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
17564
|
+
);
|
|
17565
|
+
return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
|
|
17566
|
+
}
|
|
16482
17567
|
/**
|
|
16483
17568
|
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
16484
17569
|
* suppression row to block re-import. INVARIANT 9.
|