mailery 0.8.0 → 0.9.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/dist/index.cjs +803 -661
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +803 -661
- package/dist/index.js.map +1 -1
- package/dist/{null-BCxlHRJ0.d.cts → null-BRSHAxvg.d.cts} +14 -0
- package/dist/{null-BCxlHRJ0.d.ts → null-BRSHAxvg.d.ts} +14 -0
- package/dist/testing.cjs +701 -559
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +701 -559
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -653,6 +653,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
653
653
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
654
654
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
655
655
|
{ key: { name: 1, occurredAt: -1 } },
|
|
656
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
656
657
|
{ key: { externalId: 1, name: 1 } }
|
|
657
658
|
]),
|
|
658
659
|
c.flows.createIndexes([
|
|
@@ -663,7 +664,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
663
664
|
c.flowRuns.createIndexes([
|
|
664
665
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
665
666
|
{ key: { externalId: 1, flowId: 1 } },
|
|
666
|
-
{ key: { flowId: 1, status: 1 } }
|
|
667
|
+
{ key: { flowId: 1, status: 1 } },
|
|
668
|
+
{
|
|
669
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
670
|
+
unique: true,
|
|
671
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
672
|
+
}
|
|
667
673
|
]),
|
|
668
674
|
c.templates.createIndexes([
|
|
669
675
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -891,7 +897,7 @@ var BullDriver = class _BullDriver {
|
|
|
891
897
|
bullQueues;
|
|
892
898
|
workers = null;
|
|
893
899
|
bull;
|
|
894
|
-
static async create(redisConfig) {
|
|
900
|
+
static async create(redisConfig, prefix) {
|
|
895
901
|
let bull;
|
|
896
902
|
try {
|
|
897
903
|
bull = await import('bullmq');
|
|
@@ -900,13 +906,27 @@ var BullDriver = class _BullDriver {
|
|
|
900
906
|
"mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
|
|
901
907
|
);
|
|
902
908
|
}
|
|
909
|
+
if (prefix?.includes(":")) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`mailery: queue prefix "${prefix}" must not contain ':' \u2014 BullMQ uses it as the Redis key separator.`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
903
914
|
const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
|
|
904
|
-
return new _BullDriver(bull, redis);
|
|
915
|
+
return new _BullDriver(bull, redis, prefix);
|
|
905
916
|
}
|
|
906
|
-
|
|
917
|
+
prefix;
|
|
918
|
+
constructor(bull, redis, prefix) {
|
|
907
919
|
this.bull = bull;
|
|
908
920
|
this.redis = redis;
|
|
909
|
-
|
|
921
|
+
this.prefix = prefix;
|
|
922
|
+
const opts = {
|
|
923
|
+
connection: redis,
|
|
924
|
+
prefix,
|
|
925
|
+
defaultJobOptions: {
|
|
926
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
927
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
928
|
+
}
|
|
929
|
+
};
|
|
910
930
|
this.bullQueues = {
|
|
911
931
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
912
932
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -924,12 +944,16 @@ var BullDriver = class _BullDriver {
|
|
|
924
944
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
925
945
|
"mailer-tick-repeat",
|
|
926
946
|
{ every: intervalSeconds * 1e3 },
|
|
927
|
-
{
|
|
947
|
+
{
|
|
948
|
+
name: "tick",
|
|
949
|
+
data: {},
|
|
950
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
951
|
+
}
|
|
928
952
|
);
|
|
929
953
|
}
|
|
930
954
|
async startWorkers(opts) {
|
|
931
955
|
if (this.workers) return;
|
|
932
|
-
const base = { connection: this.redis };
|
|
956
|
+
const base = { connection: this.redis, prefix: this.prefix };
|
|
933
957
|
const { Worker } = this.bull;
|
|
934
958
|
const tick = new Worker(
|
|
935
959
|
QUEUE_NAMES.tick,
|
|
@@ -1034,9 +1058,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1034
1058
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
1035
1059
|
);
|
|
1036
1060
|
}
|
|
1061
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
1037
1062
|
const backend = new backendMod.MongoBackend({
|
|
1038
1063
|
mongo: opts.db,
|
|
1039
|
-
collection:
|
|
1064
|
+
collection: collectionName
|
|
1040
1065
|
});
|
|
1041
1066
|
const agenda = new agendaMod.Agenda({
|
|
1042
1067
|
backend,
|
|
@@ -1045,13 +1070,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1045
1070
|
maxConcurrency: 50,
|
|
1046
1071
|
defaultConcurrency: 5
|
|
1047
1072
|
});
|
|
1048
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
1073
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
1049
1074
|
}
|
|
1050
1075
|
db;
|
|
1051
|
-
|
|
1076
|
+
collName;
|
|
1077
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
1052
1078
|
this.agenda = agenda;
|
|
1053
1079
|
this.agendaMod = agendaMod;
|
|
1054
1080
|
this.db = db;
|
|
1081
|
+
this.collName = collectionName;
|
|
1055
1082
|
this.queues = {
|
|
1056
1083
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
1057
1084
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -1084,10 +1111,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1084
1111
|
}
|
|
1085
1112
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
1086
1113
|
jobsCollection() {
|
|
1087
|
-
return this.db.collection(this.
|
|
1088
|
-
}
|
|
1089
|
-
collectionName() {
|
|
1090
|
-
return "_mailerJobs";
|
|
1114
|
+
return this.db.collection(this.collName);
|
|
1091
1115
|
}
|
|
1092
1116
|
async findPending(name, jobId) {
|
|
1093
1117
|
return this.jobsCollection().findOne({
|
|
@@ -1097,12 +1121,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1097
1121
|
});
|
|
1098
1122
|
}
|
|
1099
1123
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
1100
|
-
if (!this.started) {
|
|
1101
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
1102
|
-
}, { concurrency: 1 });
|
|
1103
|
-
await this.agenda.start();
|
|
1104
|
-
this.started = true;
|
|
1105
|
-
}
|
|
1106
1124
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
1107
1125
|
}
|
|
1108
1126
|
async startWorkers(opts) {
|
|
@@ -1187,7 +1205,7 @@ var NoopDriver = class {
|
|
|
1187
1205
|
async function createQueueDriver(config, fallbackDb) {
|
|
1188
1206
|
switch (config.driver) {
|
|
1189
1207
|
case "bull":
|
|
1190
|
-
return BullDriver.create(config.redis);
|
|
1208
|
+
return BullDriver.create(config.redis, config.prefix);
|
|
1191
1209
|
case "agenda":
|
|
1192
1210
|
return AgendaDriver.create({
|
|
1193
1211
|
db: config.db ?? fallbackDb,
|
|
@@ -1205,6 +1223,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
1205
1223
|
|
|
1206
1224
|
// src/server/runner/triggers.ts
|
|
1207
1225
|
var BATCH_SIZE = 1e3;
|
|
1226
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
1208
1227
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
1209
1228
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
1210
1229
|
for (const flow of flows) {
|
|
@@ -1215,16 +1234,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
1215
1234
|
const eventName = flow.trigger.eventName;
|
|
1216
1235
|
if (!eventName) return;
|
|
1217
1236
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
1218
|
-
const
|
|
1237
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
1238
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
1219
1239
|
if (events.length === 0) return;
|
|
1220
1240
|
for (const event of events) {
|
|
1221
1241
|
await tryEnterFlow(flow, event, ctx);
|
|
1222
1242
|
}
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1243
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
1244
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
1245
|
+
await ctx.collections.flows.updateOne(
|
|
1246
|
+
{ _id: flow._id },
|
|
1247
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1228
1250
|
}
|
|
1229
1251
|
async function tryEnterFlow(flow, event, ctx) {
|
|
1230
1252
|
if (flow.trigger.once) {
|
|
@@ -1236,185 +1258,559 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1236
1258
|
}
|
|
1237
1259
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
1238
1260
|
if (!sub || sub.status !== "subscribed") return;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1261
|
+
let result;
|
|
1262
|
+
try {
|
|
1263
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
1264
|
+
externalId: event.externalId,
|
|
1265
|
+
flowId: flow._id,
|
|
1266
|
+
flowSlug: flow.slug,
|
|
1267
|
+
flowVersion: flow.version,
|
|
1268
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
1269
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1270
|
+
triggerDedupeKey: event.dedupeKey,
|
|
1271
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
1272
|
+
status: "active",
|
|
1273
|
+
currentStepIndex: 0,
|
|
1274
|
+
currentBranchPath: [],
|
|
1275
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
1276
|
+
attemptsForCurrentStep: 0,
|
|
1277
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
1278
|
+
exitedAt: null,
|
|
1279
|
+
exitReason: null,
|
|
1280
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1281
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1282
|
+
});
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
if (err?.code !== 11e3) throw err;
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1258
1287
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
1259
1288
|
}
|
|
1260
1289
|
|
|
1261
|
-
// src/server/
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
const
|
|
1265
|
-
if (
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1272
|
-
}
|
|
1273
|
-
if ("subscriptionStatus" in p) {
|
|
1274
|
-
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1275
|
-
return sub?.status === p.subscriptionStatus;
|
|
1276
|
-
}
|
|
1277
|
-
if ("hasFiredEvent" in p) {
|
|
1278
|
-
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1279
|
-
}
|
|
1280
|
-
if ("notHasFiredEvent" in p) {
|
|
1281
|
-
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1282
|
-
}
|
|
1283
|
-
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1284
|
-
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1285
|
-
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1286
|
-
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1287
|
-
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1288
|
-
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1289
|
-
if ("all" in p) {
|
|
1290
|
-
for (const sub of p.all) {
|
|
1291
|
-
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1292
|
-
}
|
|
1293
|
-
return true;
|
|
1294
|
-
}
|
|
1295
|
-
if ("any" in p) {
|
|
1296
|
-
for (const sub of p.any) {
|
|
1297
|
-
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1298
|
-
}
|
|
1299
|
-
return false;
|
|
1300
|
-
}
|
|
1301
|
-
if ("not" in p) {
|
|
1302
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
1290
|
+
// src/server/templates/sender-domain.ts
|
|
1291
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1292
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1293
|
+
const domain = extractDomain(fromEmail);
|
|
1294
|
+
if (!domain) {
|
|
1295
|
+
return {
|
|
1296
|
+
ok: false,
|
|
1297
|
+
code: "invalid_email",
|
|
1298
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1299
|
+
};
|
|
1303
1300
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1314
|
-
const filter = { externalId: ctx.contact.externalId };
|
|
1315
|
-
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1316
|
-
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1317
|
-
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1318
|
-
const lower = effectiveLowerBound(ctx, opts);
|
|
1319
|
-
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1320
|
-
if (!excludeBots) {
|
|
1321
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
1301
|
+
const entry = registry[domain];
|
|
1302
|
+
if (!entry) {
|
|
1303
|
+
const known = Object.keys(registry).join(", ");
|
|
1304
|
+
return {
|
|
1305
|
+
ok: false,
|
|
1306
|
+
code: "unregistered_domain",
|
|
1307
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1308
|
+
};
|
|
1322
1309
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
}
|
|
1330
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1331
|
-
if (hasHumanClick) n++;
|
|
1310
|
+
if (entry.kind === "both") return { ok: true };
|
|
1311
|
+
if (entry.kind !== templateKind) {
|
|
1312
|
+
return {
|
|
1313
|
+
ok: false,
|
|
1314
|
+
code: "wrong_kind",
|
|
1315
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1316
|
+
};
|
|
1332
1317
|
}
|
|
1333
|
-
return
|
|
1318
|
+
return { ok: true };
|
|
1334
1319
|
}
|
|
1335
|
-
function
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
if (
|
|
1339
|
-
|
|
1340
|
-
}
|
|
1341
|
-
return null;
|
|
1320
|
+
function extractDomain(email) {
|
|
1321
|
+
if (typeof email !== "string") return null;
|
|
1322
|
+
const at = email.lastIndexOf("@");
|
|
1323
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
1324
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
1342
1325
|
}
|
|
1343
1326
|
|
|
1344
|
-
// src/server/runner/
|
|
1345
|
-
var
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1327
|
+
// src/server/runner/health.ts
|
|
1328
|
+
var ZERO_COUNTERS = {
|
|
1329
|
+
sent: 0,
|
|
1330
|
+
delivered: 0,
|
|
1331
|
+
bounced: 0,
|
|
1332
|
+
hardBounced: 0,
|
|
1333
|
+
softBounced: 0,
|
|
1334
|
+
complained: 0,
|
|
1335
|
+
failedToSend: 0
|
|
1336
|
+
};
|
|
1337
|
+
var ZERO_RATES = {
|
|
1338
|
+
bounceRate: 0,
|
|
1339
|
+
hardBounceRate: 0,
|
|
1340
|
+
complaintRate: 0,
|
|
1341
|
+
failureRate: 0
|
|
1342
|
+
};
|
|
1343
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1344
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1345
|
+
const writes = [];
|
|
1346
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1347
|
+
if (dims) {
|
|
1348
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1349
|
+
if (domain) {
|
|
1350
|
+
const id = healthBucketId(domain, dims.kind);
|
|
1351
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1367
1352
|
}
|
|
1368
1353
|
}
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
function pickTimezone(window, contactTimezone) {
|
|
1372
|
-
const candidates = [
|
|
1373
|
-
window.useContactTimezone ? contactTimezone : void 0,
|
|
1374
|
-
window.timezone,
|
|
1375
|
-
"UTC"
|
|
1376
|
-
];
|
|
1377
|
-
for (const tz of candidates) {
|
|
1378
|
-
if (tz && isValidTimezone(tz)) return tz;
|
|
1379
|
-
}
|
|
1380
|
-
return "UTC";
|
|
1354
|
+
await Promise.all(writes);
|
|
1381
1355
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1356
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1357
|
+
await ctx.collections.health.updateOne(
|
|
1358
|
+
{ _id },
|
|
1359
|
+
{
|
|
1360
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
1361
|
+
$setOnInsert: {
|
|
1362
|
+
_id,
|
|
1363
|
+
senderDomain,
|
|
1364
|
+
kind,
|
|
1365
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1366
|
+
windowDurationMs: windowMs,
|
|
1367
|
+
status: "healthy",
|
|
1368
|
+
trippedAt: null,
|
|
1369
|
+
trippedReason: null,
|
|
1370
|
+
manuallyResumedAt: null,
|
|
1371
|
+
rates: { ...ZERO_RATES }
|
|
1372
|
+
},
|
|
1373
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1374
|
+
},
|
|
1375
|
+
{ upsert: true }
|
|
1376
|
+
);
|
|
1394
1377
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
day: "2-digit",
|
|
1404
|
-
hour: "2-digit",
|
|
1405
|
-
minute: "2-digit",
|
|
1406
|
-
second: "2-digit",
|
|
1407
|
-
weekday: "short",
|
|
1408
|
-
hour12: false
|
|
1409
|
-
});
|
|
1410
|
-
partFormatters.set(tz, f);
|
|
1378
|
+
async function evaluateHealth(ctx) {
|
|
1379
|
+
const cb = ctx.config.circuitBreaker;
|
|
1380
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1381
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
1382
|
+
if (docs.length === 0) return;
|
|
1383
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1384
|
+
if (!hasAgg) {
|
|
1385
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1411
1386
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1387
|
+
for (const doc of docs) {
|
|
1388
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1389
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1390
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1391
|
+
await ctx.collections.health.updateOne(
|
|
1392
|
+
{ _id: doc._id },
|
|
1393
|
+
{
|
|
1394
|
+
$set: {
|
|
1395
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1396
|
+
windowDurationMs: windowMs,
|
|
1397
|
+
counters: { ...ZERO_COUNTERS },
|
|
1398
|
+
rates: { ...ZERO_RATES },
|
|
1399
|
+
status: "healthy",
|
|
1400
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
);
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
const c = doc.counters;
|
|
1407
|
+
const total = c.sent || 1;
|
|
1408
|
+
const rates = {
|
|
1409
|
+
bounceRate: c.bounced / total,
|
|
1410
|
+
hardBounceRate: c.hardBounced / total,
|
|
1411
|
+
complaintRate: c.complained / total,
|
|
1412
|
+
failureRate: c.failedToSend / total
|
|
1413
|
+
};
|
|
1414
|
+
await ctx.collections.health.updateOne(
|
|
1415
|
+
{ _id: doc._id },
|
|
1416
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1417
|
+
);
|
|
1418
|
+
if (isAgg) continue;
|
|
1419
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1420
|
+
if (doc.status === "tripped") continue;
|
|
1421
|
+
let trippedReason = null;
|
|
1422
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1423
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1424
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1425
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1426
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1427
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1428
|
+
}
|
|
1429
|
+
if (trippedReason) {
|
|
1430
|
+
const result = await ctx.collections.health.updateOne(
|
|
1431
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1432
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1433
|
+
);
|
|
1434
|
+
if (result.modifiedCount > 0) {
|
|
1435
|
+
if (ctx.audit) {
|
|
1436
|
+
try {
|
|
1437
|
+
await ctx.audit({
|
|
1438
|
+
actor: "system:circuit-breaker",
|
|
1439
|
+
action: "health.trip",
|
|
1440
|
+
resource: {
|
|
1441
|
+
collection: "mailer_health",
|
|
1442
|
+
id: String(doc._id),
|
|
1443
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1444
|
+
},
|
|
1445
|
+
diffSummary: trippedReason
|
|
1446
|
+
});
|
|
1447
|
+
} catch {
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
1451
|
+
try {
|
|
1452
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
1453
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1454
|
+
rates
|
|
1455
|
+
});
|
|
1456
|
+
} catch {
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1463
|
+
if (doc.status !== "degraded") {
|
|
1464
|
+
await ctx.collections.health.updateOne(
|
|
1465
|
+
{ _id: doc._id },
|
|
1466
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
} else if (doc.status === "degraded") {
|
|
1470
|
+
await ctx.collections.health.updateOne(
|
|
1471
|
+
{ _id: doc._id },
|
|
1472
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
1478
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
1479
|
+
const id = healthBucketId(domain, kind);
|
|
1480
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
1481
|
+
}
|
|
1482
|
+
function effectiveOverallStatus(docs) {
|
|
1483
|
+
const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
|
|
1484
|
+
if (buckets.length === 0) {
|
|
1485
|
+
return docs.length === 0 ? null : "healthy";
|
|
1486
|
+
}
|
|
1487
|
+
if (buckets.some((d) => d.status === "tripped")) return "tripped";
|
|
1488
|
+
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1489
|
+
return "healthy";
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/server/runner/webhook.ts
|
|
1493
|
+
function dimsFromSend(send) {
|
|
1494
|
+
if (!send) return null;
|
|
1495
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
1496
|
+
}
|
|
1497
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
1498
|
+
const filter = { processed: false };
|
|
1499
|
+
if (opts.olderThanMs) {
|
|
1500
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
1501
|
+
}
|
|
1502
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
1503
|
+
for (const evt of batch) {
|
|
1504
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
1505
|
+
{ _id: evt._id, processed: false },
|
|
1506
|
+
{ $set: { processed: true } }
|
|
1507
|
+
);
|
|
1508
|
+
if (!claimed) continue;
|
|
1509
|
+
try {
|
|
1510
|
+
const normalized = evt.raw?.normalized;
|
|
1511
|
+
const details = normalized?.details ?? {};
|
|
1512
|
+
await applyWebhookEvent(
|
|
1513
|
+
{
|
|
1514
|
+
type: evt.normalizedType,
|
|
1515
|
+
providerEventId: evt.providerEventId,
|
|
1516
|
+
providerMessageId: evt.providerMessageId,
|
|
1517
|
+
email: evt.email,
|
|
1518
|
+
occurredAt: evt.occurredAt,
|
|
1519
|
+
details
|
|
1520
|
+
},
|
|
1521
|
+
ctx
|
|
1522
|
+
);
|
|
1523
|
+
} catch (err) {
|
|
1524
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
async function applyWebhookEvent(event, ctx) {
|
|
1529
|
+
const send = await ctx.collections.sends.findOne(
|
|
1530
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
1531
|
+
{ sort: { queuedAt: -1 } }
|
|
1532
|
+
);
|
|
1533
|
+
switch (event.type) {
|
|
1534
|
+
case "delivered":
|
|
1535
|
+
if (send) {
|
|
1536
|
+
await ctx.collections.sends.updateOne(
|
|
1537
|
+
{ _id: send._id },
|
|
1538
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
1542
|
+
break;
|
|
1543
|
+
case "open":
|
|
1544
|
+
if (send) {
|
|
1545
|
+
await ctx.collections.sends.updateOne(
|
|
1546
|
+
{ _id: send._id },
|
|
1547
|
+
{
|
|
1548
|
+
$set: {
|
|
1549
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
1550
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
1551
|
+
},
|
|
1552
|
+
$inc: { openCount: 1 }
|
|
1553
|
+
}
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
break;
|
|
1557
|
+
case "click":
|
|
1558
|
+
if (send) {
|
|
1559
|
+
await ctx.collections.sends.updateOne(
|
|
1560
|
+
{ _id: send._id },
|
|
1561
|
+
{
|
|
1562
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
1563
|
+
$inc: { clickCount: 1 },
|
|
1564
|
+
$push: {
|
|
1565
|
+
clickedLinks: {
|
|
1566
|
+
url: event.details.clickedUrl ?? "",
|
|
1567
|
+
linkId: "",
|
|
1568
|
+
clickedAt: event.occurredAt
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
break;
|
|
1575
|
+
case "bounce": {
|
|
1576
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
1577
|
+
if (send) {
|
|
1578
|
+
await ctx.collections.sends.updateOne(
|
|
1579
|
+
{ _id: send._id },
|
|
1580
|
+
{
|
|
1581
|
+
$set: {
|
|
1582
|
+
status: "bounced",
|
|
1583
|
+
bounceType,
|
|
1584
|
+
bounceReason: event.details.bounceReason ?? null
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
if (bounceType === "hard") {
|
|
1590
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
1591
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1592
|
+
{ emailAtSubscribe: event.email },
|
|
1593
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
{
|
|
1597
|
+
const dims = dimsFromSend(send);
|
|
1598
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
1599
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
1600
|
+
}
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
case "complaint":
|
|
1604
|
+
case "spam_report":
|
|
1605
|
+
if (send) {
|
|
1606
|
+
await ctx.collections.sends.updateOne(
|
|
1607
|
+
{ _id: send._id },
|
|
1608
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
1612
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1613
|
+
{ emailAtSubscribe: event.email },
|
|
1614
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1615
|
+
);
|
|
1616
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
1617
|
+
break;
|
|
1618
|
+
case "unsubscribe":
|
|
1619
|
+
if (send) {
|
|
1620
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
1621
|
+
}
|
|
1622
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
1623
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1624
|
+
{ emailAtSubscribe: event.email },
|
|
1625
|
+
{
|
|
1626
|
+
$set: {
|
|
1627
|
+
status: "unsubscribed",
|
|
1628
|
+
unsubscribedAt: event.occurredAt,
|
|
1629
|
+
unsubscribeReason: "user_request",
|
|
1630
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
);
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
1638
|
+
const normalized = email.toLowerCase();
|
|
1639
|
+
await ctx.collections.suppressions.updateOne(
|
|
1640
|
+
{ email: normalized, scope },
|
|
1641
|
+
{
|
|
1642
|
+
$setOnInsert: {
|
|
1643
|
+
email: normalized,
|
|
1644
|
+
emailHash: sha256Hex(normalized),
|
|
1645
|
+
scope,
|
|
1646
|
+
reason,
|
|
1647
|
+
source: "provider_webhook",
|
|
1648
|
+
notes: null,
|
|
1649
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
1650
|
+
expiresAt: null
|
|
1651
|
+
}
|
|
1652
|
+
},
|
|
1653
|
+
{ upsert: true }
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/server/runner/predicate.ts
|
|
1658
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1659
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
1660
|
+
const p = predicate;
|
|
1661
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
1662
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
1663
|
+
if ("fieldEquals" in p) {
|
|
1664
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
1665
|
+
}
|
|
1666
|
+
if ("fieldExists" in p) {
|
|
1667
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1668
|
+
}
|
|
1669
|
+
if ("subscriptionStatus" in p) {
|
|
1670
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1671
|
+
return sub?.status === p.subscriptionStatus;
|
|
1672
|
+
}
|
|
1673
|
+
if ("hasFiredEvent" in p) {
|
|
1674
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1675
|
+
}
|
|
1676
|
+
if ("notHasFiredEvent" in p) {
|
|
1677
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1678
|
+
}
|
|
1679
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1680
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1681
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1682
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1683
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1684
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1685
|
+
if ("all" in p) {
|
|
1686
|
+
for (const sub of p.all) {
|
|
1687
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1688
|
+
}
|
|
1689
|
+
return true;
|
|
1690
|
+
}
|
|
1691
|
+
if ("any" in p) {
|
|
1692
|
+
for (const sub of p.any) {
|
|
1693
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1694
|
+
}
|
|
1695
|
+
return false;
|
|
1696
|
+
}
|
|
1697
|
+
if ("not" in p) {
|
|
1698
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
1699
|
+
}
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
async function hasEvent(ctx, name, opts) {
|
|
1703
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
1704
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1705
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
1706
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
1707
|
+
return !!found;
|
|
1708
|
+
}
|
|
1709
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1710
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
1711
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1712
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1713
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1714
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1715
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1716
|
+
if (!excludeBots) {
|
|
1717
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
1718
|
+
}
|
|
1719
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1720
|
+
let n = 0;
|
|
1721
|
+
for (const s of docs) {
|
|
1722
|
+
if (kind === "opened") {
|
|
1723
|
+
n++;
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1727
|
+
if (hasHumanClick) n++;
|
|
1728
|
+
}
|
|
1729
|
+
return n;
|
|
1730
|
+
}
|
|
1731
|
+
function effectiveLowerBound(ctx, opts) {
|
|
1732
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1733
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
1734
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
1735
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
1736
|
+
}
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// src/server/runner/delivery-window.ts
|
|
1741
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1742
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1743
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1744
|
+
let candidate = now;
|
|
1745
|
+
if (window.timeOfDay) {
|
|
1746
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1747
|
+
const local = localParts(candidate, tz);
|
|
1748
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1749
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1750
|
+
candidate = todaySlot;
|
|
1751
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1752
|
+
const next = addLocalDays(local, 1);
|
|
1753
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
if (window.weekdaysOnly) {
|
|
1757
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1758
|
+
const local = localParts(candidate, tz);
|
|
1759
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1760
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1761
|
+
const moved = addLocalDays(local, shift);
|
|
1762
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return candidate;
|
|
1766
|
+
}
|
|
1767
|
+
function pickTimezone(window, contactTimezone) {
|
|
1768
|
+
const candidates = [
|
|
1769
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1770
|
+
window.timezone,
|
|
1771
|
+
"UTC"
|
|
1772
|
+
];
|
|
1773
|
+
for (const tz of candidates) {
|
|
1774
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1775
|
+
}
|
|
1776
|
+
return "UTC";
|
|
1777
|
+
}
|
|
1778
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1779
|
+
function isValidTimezone(tz) {
|
|
1780
|
+
const cached = validatedZones.get(tz);
|
|
1781
|
+
if (cached !== void 0) return cached;
|
|
1782
|
+
let ok = true;
|
|
1783
|
+
try {
|
|
1784
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1785
|
+
} catch {
|
|
1786
|
+
ok = false;
|
|
1787
|
+
}
|
|
1788
|
+
validatedZones.set(tz, ok);
|
|
1789
|
+
return ok;
|
|
1790
|
+
}
|
|
1791
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1792
|
+
function formatterFor(tz) {
|
|
1793
|
+
let f = partFormatters.get(tz);
|
|
1794
|
+
if (!f) {
|
|
1795
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1796
|
+
timeZone: tz,
|
|
1797
|
+
year: "numeric",
|
|
1798
|
+
month: "2-digit",
|
|
1799
|
+
day: "2-digit",
|
|
1800
|
+
hour: "2-digit",
|
|
1801
|
+
minute: "2-digit",
|
|
1802
|
+
second: "2-digit",
|
|
1803
|
+
weekday: "short",
|
|
1804
|
+
hour12: false
|
|
1805
|
+
});
|
|
1806
|
+
partFormatters.set(tz, f);
|
|
1807
|
+
}
|
|
1808
|
+
return f;
|
|
1809
|
+
}
|
|
1810
|
+
function localParts(date, tz) {
|
|
1811
|
+
const parts = {};
|
|
1812
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1813
|
+
return {
|
|
1418
1814
|
y: Number(parts.year),
|
|
1419
1815
|
mo: Number(parts.month),
|
|
1420
1816
|
d: Number(parts.day),
|
|
@@ -1487,7 +1883,8 @@ function applyTracking(html, opts) {
|
|
|
1487
1883
|
const seen = /* @__PURE__ */ new Map();
|
|
1488
1884
|
let out = html;
|
|
1489
1885
|
if (opts.trackClicks) {
|
|
1490
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
1886
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
1887
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
1491
1888
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
1492
1889
|
let linkId = seen.get(url);
|
|
1493
1890
|
if (!linkId) {
|
|
@@ -1519,6 +1916,29 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
1519
1916
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
1520
1917
|
return false;
|
|
1521
1918
|
}
|
|
1919
|
+
var NAMED_ENTITIES = {
|
|
1920
|
+
amp: "&",
|
|
1921
|
+
lt: "<",
|
|
1922
|
+
gt: ">",
|
|
1923
|
+
quot: '"',
|
|
1924
|
+
apos: "'",
|
|
1925
|
+
nbsp: "\xA0"
|
|
1926
|
+
};
|
|
1927
|
+
function decodeHtmlEntities(input) {
|
|
1928
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
1929
|
+
if (body[0] === "#") {
|
|
1930
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
1931
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
1932
|
+
try {
|
|
1933
|
+
return String.fromCodePoint(code);
|
|
1934
|
+
} catch {
|
|
1935
|
+
return match;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
1939
|
+
return named ?? match;
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1522
1942
|
function shortHash(input) {
|
|
1523
1943
|
return crypto2__default.default.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
1524
1944
|
}
|
|
@@ -1527,266 +1947,64 @@ function makeHandlebars(extra) {
|
|
|
1527
1947
|
hb.registerHelper("eq", (a, b) => a === b);
|
|
1528
1948
|
hb.registerHelper("ne", (a, b) => a !== b);
|
|
1529
1949
|
hb.registerHelper("gt", (a, b) => a > b);
|
|
1530
|
-
hb.registerHelper("lt", (a, b) => a < b);
|
|
1531
|
-
hb.registerHelper("gte", (a, b) => a >= b);
|
|
1532
|
-
hb.registerHelper("lte", (a, b) => a <= b);
|
|
1533
|
-
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
1534
|
-
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1535
|
-
hb.registerHelper("not", (a) => !a);
|
|
1536
|
-
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1537
|
-
if (!value) return "";
|
|
1538
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
1539
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
1540
|
-
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1541
|
-
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1542
|
-
return d.toISOString().slice(0, 10);
|
|
1543
|
-
});
|
|
1544
|
-
hb.registerHelper("formatNumber", (n) => {
|
|
1545
|
-
const v = Number(n);
|
|
1546
|
-
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1547
|
-
});
|
|
1548
|
-
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1549
|
-
const v = Number(cents);
|
|
1550
|
-
if (!Number.isFinite(v)) return "";
|
|
1551
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1552
|
-
});
|
|
1553
|
-
hb.registerHelper(
|
|
1554
|
-
"pluralize",
|
|
1555
|
-
(n, one, many) => Number(n) === 1 ? one : many
|
|
1556
|
-
);
|
|
1557
|
-
if (extra) {
|
|
1558
|
-
for (const [name, fn] of Object.entries(extra)) {
|
|
1559
|
-
hb.registerHelper(name, fn);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
return hb;
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// src/server/runner/send.ts
|
|
1566
|
-
init_vars();
|
|
1567
|
-
|
|
1568
|
-
// src/server/runner/suppression.ts
|
|
1569
|
-
var SCOPES_BY_KIND = {
|
|
1570
|
-
marketing: ["all", "marketing"],
|
|
1571
|
-
transactional: ["all", "transactional"]
|
|
1572
|
-
};
|
|
1573
|
-
async function isSuppressed(collections, email, kind) {
|
|
1574
|
-
const normalized = email.toLowerCase();
|
|
1575
|
-
const allowed = SCOPES_BY_KIND[kind];
|
|
1576
|
-
const byEmail = await collections.suppressions.findOne({
|
|
1577
|
-
email: normalized,
|
|
1578
|
-
scope: { $in: allowed },
|
|
1579
|
-
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
1580
|
-
});
|
|
1581
|
-
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
1582
|
-
const hashed = await collections.suppressions.findOne({
|
|
1583
|
-
emailHash: sha256Hex(normalized),
|
|
1584
|
-
scope: { $in: allowed }
|
|
1585
|
-
});
|
|
1586
|
-
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
1587
|
-
return { suppressed: false };
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
// src/server/templates/sender-domain.ts
|
|
1591
|
-
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1592
|
-
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1593
|
-
const domain = extractDomain(fromEmail);
|
|
1594
|
-
if (!domain) {
|
|
1595
|
-
return {
|
|
1596
|
-
ok: false,
|
|
1597
|
-
code: "invalid_email",
|
|
1598
|
-
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1599
|
-
};
|
|
1600
|
-
}
|
|
1601
|
-
const entry = registry[domain];
|
|
1602
|
-
if (!entry) {
|
|
1603
|
-
const known = Object.keys(registry).join(", ");
|
|
1604
|
-
return {
|
|
1605
|
-
ok: false,
|
|
1606
|
-
code: "unregistered_domain",
|
|
1607
|
-
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1608
|
-
};
|
|
1609
|
-
}
|
|
1610
|
-
if (entry.kind === "both") return { ok: true };
|
|
1611
|
-
if (entry.kind !== templateKind) {
|
|
1612
|
-
return {
|
|
1613
|
-
ok: false,
|
|
1614
|
-
code: "wrong_kind",
|
|
1615
|
-
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1616
|
-
};
|
|
1617
|
-
}
|
|
1618
|
-
return { ok: true };
|
|
1619
|
-
}
|
|
1620
|
-
function extractDomain(email) {
|
|
1621
|
-
if (typeof email !== "string") return null;
|
|
1622
|
-
const at = email.lastIndexOf("@");
|
|
1623
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
1624
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
// src/server/runner/health.ts
|
|
1628
|
-
var ZERO_COUNTERS = {
|
|
1629
|
-
sent: 0,
|
|
1630
|
-
delivered: 0,
|
|
1631
|
-
bounced: 0,
|
|
1632
|
-
hardBounced: 0,
|
|
1633
|
-
softBounced: 0,
|
|
1634
|
-
complained: 0,
|
|
1635
|
-
failedToSend: 0
|
|
1636
|
-
};
|
|
1637
|
-
var ZERO_RATES = {
|
|
1638
|
-
bounceRate: 0,
|
|
1639
|
-
hardBounceRate: 0,
|
|
1640
|
-
complaintRate: 0,
|
|
1641
|
-
failureRate: 0
|
|
1642
|
-
};
|
|
1643
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1644
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1645
|
-
const writes = [];
|
|
1646
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1647
|
-
if (dims) {
|
|
1648
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1649
|
-
if (domain) {
|
|
1650
|
-
const id = healthBucketId(domain, dims.kind);
|
|
1651
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
await Promise.all(writes);
|
|
1655
|
-
}
|
|
1656
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1657
|
-
await ctx.collections.health.updateOne(
|
|
1658
|
-
{ _id },
|
|
1659
|
-
{
|
|
1660
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
1661
|
-
$setOnInsert: {
|
|
1662
|
-
_id,
|
|
1663
|
-
senderDomain,
|
|
1664
|
-
kind,
|
|
1665
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1666
|
-
windowDurationMs: windowMs,
|
|
1667
|
-
status: "healthy",
|
|
1668
|
-
trippedAt: null,
|
|
1669
|
-
trippedReason: null,
|
|
1670
|
-
manuallyResumedAt: null,
|
|
1671
|
-
rates: { ...ZERO_RATES }
|
|
1672
|
-
},
|
|
1673
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1674
|
-
},
|
|
1675
|
-
{ upsert: true }
|
|
1676
|
-
);
|
|
1677
|
-
}
|
|
1678
|
-
async function evaluateHealth(ctx) {
|
|
1679
|
-
const cb = ctx.config.circuitBreaker;
|
|
1680
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1681
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
1682
|
-
if (docs.length === 0) return;
|
|
1683
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1684
|
-
if (!hasAgg) {
|
|
1685
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1686
|
-
}
|
|
1687
|
-
for (const doc of docs) {
|
|
1688
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1689
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1690
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1691
|
-
await ctx.collections.health.updateOne(
|
|
1692
|
-
{ _id: doc._id },
|
|
1693
|
-
{
|
|
1694
|
-
$set: {
|
|
1695
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1696
|
-
windowDurationMs: windowMs,
|
|
1697
|
-
counters: { ...ZERO_COUNTERS },
|
|
1698
|
-
rates: { ...ZERO_RATES },
|
|
1699
|
-
status: "healthy",
|
|
1700
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
);
|
|
1704
|
-
continue;
|
|
1705
|
-
}
|
|
1706
|
-
const c = doc.counters;
|
|
1707
|
-
const total = c.sent || 1;
|
|
1708
|
-
const rates = {
|
|
1709
|
-
bounceRate: c.bounced / total,
|
|
1710
|
-
hardBounceRate: c.hardBounced / total,
|
|
1711
|
-
complaintRate: c.complained / total,
|
|
1712
|
-
failureRate: c.failedToSend / total
|
|
1713
|
-
};
|
|
1714
|
-
await ctx.collections.health.updateOne(
|
|
1715
|
-
{ _id: doc._id },
|
|
1716
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1717
|
-
);
|
|
1718
|
-
if (isAgg) continue;
|
|
1719
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1720
|
-
if (doc.status === "tripped") continue;
|
|
1721
|
-
let trippedReason = null;
|
|
1722
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1723
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1724
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1725
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1726
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1727
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1728
|
-
}
|
|
1729
|
-
if (trippedReason) {
|
|
1730
|
-
const result = await ctx.collections.health.updateOne(
|
|
1731
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1732
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1733
|
-
);
|
|
1734
|
-
if (result.modifiedCount > 0) {
|
|
1735
|
-
if (ctx.audit) {
|
|
1736
|
-
try {
|
|
1737
|
-
await ctx.audit({
|
|
1738
|
-
actor: "system:circuit-breaker",
|
|
1739
|
-
action: "health.trip",
|
|
1740
|
-
resource: {
|
|
1741
|
-
collection: "mailer_health",
|
|
1742
|
-
id: String(doc._id),
|
|
1743
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1744
|
-
},
|
|
1745
|
-
diffSummary: trippedReason
|
|
1746
|
-
});
|
|
1747
|
-
} catch {
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
1751
|
-
try {
|
|
1752
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
1753
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1754
|
-
rates
|
|
1755
|
-
});
|
|
1756
|
-
} catch {
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
continue;
|
|
1761
|
-
}
|
|
1762
|
-
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1763
|
-
if (doc.status !== "degraded") {
|
|
1764
|
-
await ctx.collections.health.updateOne(
|
|
1765
|
-
{ _id: doc._id },
|
|
1766
|
-
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
} else if (doc.status === "degraded") {
|
|
1770
|
-
await ctx.collections.health.updateOne(
|
|
1771
|
-
{ _id: doc._id },
|
|
1772
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1773
|
-
);
|
|
1950
|
+
hb.registerHelper("lt", (a, b) => a < b);
|
|
1951
|
+
hb.registerHelper("gte", (a, b) => a >= b);
|
|
1952
|
+
hb.registerHelper("lte", (a, b) => a <= b);
|
|
1953
|
+
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
1954
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1955
|
+
hb.registerHelper("not", (a) => !a);
|
|
1956
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1957
|
+
if (!value) return "";
|
|
1958
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
1959
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
1960
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1961
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1962
|
+
return d.toISOString().slice(0, 10);
|
|
1963
|
+
});
|
|
1964
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
1965
|
+
const v = Number(n);
|
|
1966
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1967
|
+
});
|
|
1968
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1969
|
+
const v = Number(cents);
|
|
1970
|
+
if (!Number.isFinite(v)) return "";
|
|
1971
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1972
|
+
});
|
|
1973
|
+
hb.registerHelper(
|
|
1974
|
+
"pluralize",
|
|
1975
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
1976
|
+
);
|
|
1977
|
+
if (extra) {
|
|
1978
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
1979
|
+
hb.registerHelper(name, fn);
|
|
1774
1980
|
}
|
|
1775
1981
|
}
|
|
1982
|
+
return hb;
|
|
1776
1983
|
}
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1984
|
+
|
|
1985
|
+
// src/server/runner/send.ts
|
|
1986
|
+
init_vars();
|
|
1987
|
+
|
|
1988
|
+
// src/server/runner/suppression.ts
|
|
1989
|
+
var SCOPES_BY_KIND = {
|
|
1990
|
+
marketing: ["all", "marketing"],
|
|
1991
|
+
transactional: ["all", "transactional"]
|
|
1992
|
+
};
|
|
1993
|
+
async function isSuppressed(collections, email, kind) {
|
|
1994
|
+
const normalized = email.toLowerCase();
|
|
1995
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
1996
|
+
const byEmail = await collections.suppressions.findOne({
|
|
1997
|
+
email: normalized,
|
|
1998
|
+
scope: { $in: allowed },
|
|
1999
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
2000
|
+
});
|
|
2001
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
2002
|
+
const hashed = await collections.suppressions.findOne({
|
|
2003
|
+
emailHash: sha256Hex(normalized),
|
|
2004
|
+
scope: { $in: allowed }
|
|
2005
|
+
});
|
|
2006
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
2007
|
+
return { suppressed: false };
|
|
1790
2008
|
}
|
|
1791
2009
|
|
|
1792
2010
|
// src/server/runner/send.ts
|
|
@@ -1854,9 +2072,11 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
1854
2072
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
1855
2073
|
}
|
|
1856
2074
|
async function dispatchSend(sendId, ctx) {
|
|
1857
|
-
const send = await ctx.collections.sends.
|
|
2075
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
2076
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
2077
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2078
|
+
);
|
|
1858
2079
|
if (!send) return;
|
|
1859
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
1860
2080
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
1861
2081
|
if (!template) {
|
|
1862
2082
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -1873,6 +2093,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1873
2093
|
if (send.kind === "marketing") {
|
|
1874
2094
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
1875
2095
|
if (bucket?.status === "tripped") {
|
|
2096
|
+
await ctx.collections.sends.updateOne(
|
|
2097
|
+
{ _id: send._id },
|
|
2098
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2099
|
+
);
|
|
1876
2100
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
1877
2101
|
return;
|
|
1878
2102
|
}
|
|
@@ -2121,7 +2345,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2121
2345
|
await ctx.queues.advance.add(
|
|
2122
2346
|
"advance",
|
|
2123
2347
|
{ flowRunId: String(run._id) },
|
|
2124
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
2348
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
2125
2349
|
);
|
|
2126
2350
|
}
|
|
2127
2351
|
async function handleCondition(run, step, contact, ctx) {
|
|
@@ -2233,7 +2457,7 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
2233
2457
|
await ctx.queues.advance.add(
|
|
2234
2458
|
"advance",
|
|
2235
2459
|
{ flowRunId: String(run._id) },
|
|
2236
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2460
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2237
2461
|
);
|
|
2238
2462
|
}
|
|
2239
2463
|
}
|
|
@@ -2262,7 +2486,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
2262
2486
|
{ flowRunId: String(run._id) },
|
|
2263
2487
|
{
|
|
2264
2488
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2265
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2489
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2266
2490
|
}
|
|
2267
2491
|
);
|
|
2268
2492
|
}
|
|
@@ -2317,13 +2541,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
2317
2541
|
let arr = steps;
|
|
2318
2542
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
2319
2543
|
const parentIndex = branchPath[i];
|
|
2320
|
-
const
|
|
2544
|
+
const branchKey2 = branchPath[i + 1];
|
|
2321
2545
|
const parent = arr[parentIndex];
|
|
2322
2546
|
if (!parent || parent.type !== "branch") return null;
|
|
2323
|
-
arr =
|
|
2547
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
2324
2548
|
}
|
|
2325
2549
|
return arr[currentStepIndex] ?? null;
|
|
2326
2550
|
}
|
|
2551
|
+
function branchKey(run) {
|
|
2552
|
+
return run.currentBranchPath.join("_");
|
|
2553
|
+
}
|
|
2327
2554
|
function unitToMs(value, unit) {
|
|
2328
2555
|
const m = 6e4;
|
|
2329
2556
|
switch (unit) {
|
|
@@ -2350,6 +2577,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
2350
2577
|
}
|
|
2351
2578
|
}
|
|
2352
2579
|
}
|
|
2580
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
2353
2581
|
async function processScheduledBroadcasts(ctx) {
|
|
2354
2582
|
const now = /* @__PURE__ */ new Date();
|
|
2355
2583
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -2360,15 +2588,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2360
2588
|
{ returnDocument: "after" }
|
|
2361
2589
|
);
|
|
2362
2590
|
if (!claimed) continue;
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2591
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
2595
|
+
if (ctx.config.queue.driver === "noop") {
|
|
2596
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
await ctx.queues.advance.add(
|
|
2600
|
+
"advance",
|
|
2601
|
+
{ broadcastId: String(broadcast._id) },
|
|
2602
|
+
{
|
|
2603
|
+
attempts: 3,
|
|
2604
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
2605
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
2371
2606
|
}
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
2610
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
2611
|
+
if (!broadcast) return;
|
|
2612
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2613
|
+
}
|
|
2614
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
2615
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
2616
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
2617
|
+
for (const b of stalled) {
|
|
2618
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2619
|
+
{ _id: b._id },
|
|
2620
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2621
|
+
);
|
|
2622
|
+
await startBroadcastDispatch(b, ctx);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
2626
|
+
try {
|
|
2627
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
2628
|
+
} catch (err) {
|
|
2629
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
2630
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2631
|
+
{ _id: broadcast._id },
|
|
2632
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2633
|
+
);
|
|
2372
2634
|
}
|
|
2373
2635
|
}
|
|
2374
2636
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -2389,11 +2651,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
2389
2651
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
2390
2652
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
2391
2653
|
for (; ; ) {
|
|
2654
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2655
|
+
{ _id: broadcast._id },
|
|
2656
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2657
|
+
);
|
|
2392
2658
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
2393
2659
|
if (page.contacts.length === 0) break;
|
|
2394
2660
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
2395
2661
|
if (eligible.length > 0) {
|
|
2396
2662
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
2663
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2664
|
+
{ _id: broadcast._id },
|
|
2665
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2666
|
+
);
|
|
2397
2667
|
await sleep(2e3);
|
|
2398
2668
|
}
|
|
2399
2669
|
const sendDocs = await Promise.all(
|
|
@@ -2516,7 +2786,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2516
2786
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
2517
2787
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
2518
2788
|
if (respectTimezone && contact.timezone) {
|
|
2519
|
-
|
|
2789
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
2790
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
2520
2791
|
}
|
|
2521
2792
|
const doc = {
|
|
2522
2793
|
_id: sendId,
|
|
@@ -2555,7 +2826,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2555
2826
|
};
|
|
2556
2827
|
return { doc, delayMs };
|
|
2557
2828
|
}
|
|
2558
|
-
function
|
|
2829
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
2830
|
+
const DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2559
2831
|
try {
|
|
2560
2832
|
const scheduled = new Date(scheduledMs);
|
|
2561
2833
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -2568,7 +2840,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
2568
2840
|
const utcMs = parse(utc);
|
|
2569
2841
|
const localMs = parse(local);
|
|
2570
2842
|
const offsetMs = utcMs - localMs;
|
|
2571
|
-
return offsetMs;
|
|
2843
|
+
return (offsetMs % DAY_MS2 + DAY_MS2) % DAY_MS2;
|
|
2572
2844
|
} catch {
|
|
2573
2845
|
return 0;
|
|
2574
2846
|
}
|
|
@@ -3515,6 +3787,7 @@ function suggestPolicyProgression(input) {
|
|
|
3515
3787
|
|
|
3516
3788
|
// src/server/runner/tick.ts
|
|
3517
3789
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3790
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3518
3791
|
async function runTick(ctx) {
|
|
3519
3792
|
try {
|
|
3520
3793
|
await ctx.collections.health.updateOne(
|
|
@@ -3555,12 +3828,18 @@ async function runTick(ctx) {
|
|
|
3555
3828
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3556
3829
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3557
3830
|
});
|
|
3831
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
3832
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
3833
|
+
});
|
|
3558
3834
|
await evaluateHealth(ctx).catch((err) => {
|
|
3559
3835
|
console.error("mailery: health evaluation failed", err);
|
|
3560
3836
|
});
|
|
3561
3837
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
3562
3838
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
3563
3839
|
});
|
|
3840
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
3841
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
3842
|
+
});
|
|
3564
3843
|
await Promise.all([
|
|
3565
3844
|
runDnsblChecks(ctx).catch((err) => {
|
|
3566
3845
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -3631,140 +3910,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
3631
3910
|
await processScheduledBroadcasts(ctx);
|
|
3632
3911
|
}
|
|
3633
3912
|
|
|
3634
|
-
// src/server/runner/webhook.ts
|
|
3635
|
-
function dimsFromSend(send) {
|
|
3636
|
-
if (!send) return null;
|
|
3637
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
3638
|
-
}
|
|
3639
|
-
async function applyWebhookEvent(event, ctx) {
|
|
3640
|
-
const send = await ctx.collections.sends.findOne(
|
|
3641
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
3642
|
-
{ sort: { queuedAt: -1 } }
|
|
3643
|
-
);
|
|
3644
|
-
switch (event.type) {
|
|
3645
|
-
case "delivered":
|
|
3646
|
-
if (send) {
|
|
3647
|
-
await ctx.collections.sends.updateOne(
|
|
3648
|
-
{ _id: send._id },
|
|
3649
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
3650
|
-
);
|
|
3651
|
-
}
|
|
3652
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
3653
|
-
break;
|
|
3654
|
-
case "open":
|
|
3655
|
-
if (send) {
|
|
3656
|
-
await ctx.collections.sends.updateOne(
|
|
3657
|
-
{ _id: send._id },
|
|
3658
|
-
{
|
|
3659
|
-
$set: {
|
|
3660
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
3661
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
3662
|
-
},
|
|
3663
|
-
$inc: { openCount: 1 }
|
|
3664
|
-
}
|
|
3665
|
-
);
|
|
3666
|
-
}
|
|
3667
|
-
break;
|
|
3668
|
-
case "click":
|
|
3669
|
-
if (send) {
|
|
3670
|
-
await ctx.collections.sends.updateOne(
|
|
3671
|
-
{ _id: send._id },
|
|
3672
|
-
{
|
|
3673
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
3674
|
-
$inc: { clickCount: 1 },
|
|
3675
|
-
$push: {
|
|
3676
|
-
clickedLinks: {
|
|
3677
|
-
url: event.details.clickedUrl ?? "",
|
|
3678
|
-
linkId: "",
|
|
3679
|
-
clickedAt: event.occurredAt
|
|
3680
|
-
}
|
|
3681
|
-
}
|
|
3682
|
-
}
|
|
3683
|
-
);
|
|
3684
|
-
}
|
|
3685
|
-
break;
|
|
3686
|
-
case "bounce": {
|
|
3687
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
3688
|
-
if (send) {
|
|
3689
|
-
await ctx.collections.sends.updateOne(
|
|
3690
|
-
{ _id: send._id },
|
|
3691
|
-
{
|
|
3692
|
-
$set: {
|
|
3693
|
-
status: "bounced",
|
|
3694
|
-
bounceType,
|
|
3695
|
-
bounceReason: event.details.bounceReason ?? null
|
|
3696
|
-
}
|
|
3697
|
-
}
|
|
3698
|
-
);
|
|
3699
|
-
}
|
|
3700
|
-
if (bounceType === "hard") {
|
|
3701
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
3702
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3703
|
-
{ emailAtSubscribe: event.email },
|
|
3704
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3705
|
-
);
|
|
3706
|
-
}
|
|
3707
|
-
{
|
|
3708
|
-
const dims = dimsFromSend(send);
|
|
3709
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
3710
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
3711
|
-
}
|
|
3712
|
-
break;
|
|
3713
|
-
}
|
|
3714
|
-
case "complaint":
|
|
3715
|
-
case "spam_report":
|
|
3716
|
-
if (send) {
|
|
3717
|
-
await ctx.collections.sends.updateOne(
|
|
3718
|
-
{ _id: send._id },
|
|
3719
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
3720
|
-
);
|
|
3721
|
-
}
|
|
3722
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
3723
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3724
|
-
{ emailAtSubscribe: event.email },
|
|
3725
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3726
|
-
);
|
|
3727
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
3728
|
-
break;
|
|
3729
|
-
case "unsubscribe":
|
|
3730
|
-
if (send) {
|
|
3731
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
3732
|
-
}
|
|
3733
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
3734
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3735
|
-
{ emailAtSubscribe: event.email },
|
|
3736
|
-
{
|
|
3737
|
-
$set: {
|
|
3738
|
-
status: "unsubscribed",
|
|
3739
|
-
unsubscribedAt: event.occurredAt,
|
|
3740
|
-
unsubscribeReason: "user_request",
|
|
3741
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
3742
|
-
}
|
|
3743
|
-
}
|
|
3744
|
-
);
|
|
3745
|
-
break;
|
|
3746
|
-
}
|
|
3747
|
-
}
|
|
3748
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
3749
|
-
const normalized = email.toLowerCase();
|
|
3750
|
-
await ctx.collections.suppressions.updateOne(
|
|
3751
|
-
{ email: normalized, scope },
|
|
3752
|
-
{
|
|
3753
|
-
$setOnInsert: {
|
|
3754
|
-
email: normalized,
|
|
3755
|
-
emailHash: sha256Hex(normalized),
|
|
3756
|
-
scope,
|
|
3757
|
-
reason,
|
|
3758
|
-
source: "provider_webhook",
|
|
3759
|
-
notes: null,
|
|
3760
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
3761
|
-
expiresAt: null
|
|
3762
|
-
}
|
|
3763
|
-
},
|
|
3764
|
-
{ upsert: true }
|
|
3765
|
-
);
|
|
3766
|
-
}
|
|
3767
|
-
|
|
3768
3913
|
// src/server/mailer.ts
|
|
3769
3914
|
var Mailer = class _Mailer {
|
|
3770
3915
|
db;
|
|
@@ -3804,6 +3949,7 @@ var Mailer = class _Mailer {
|
|
|
3804
3949
|
* MAILER_MONGODB_URI — Mongo connection string (required)
|
|
3805
3950
|
* MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
|
|
3806
3951
|
* MAILER_REDIS_URL — Redis connection URL (required)
|
|
3952
|
+
* MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
|
|
3807
3953
|
* MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
|
|
3808
3954
|
* MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
|
|
3809
3955
|
* MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
|
|
@@ -3852,7 +3998,11 @@ var Mailer = class _Mailer {
|
|
|
3852
3998
|
}
|
|
3853
3999
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
3854
4000
|
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
3855
|
-
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
4001
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
4002
|
+
driver: "bull",
|
|
4003
|
+
redis: { url: required("MAILER_REDIS_URL") },
|
|
4004
|
+
prefix: env.MAILER_QUEUE_PREFIX
|
|
4005
|
+
};
|
|
3856
4006
|
return _Mailer.init({
|
|
3857
4007
|
db,
|
|
3858
4008
|
adapter,
|
|
@@ -4229,41 +4379,48 @@ var Mailer = class _Mailer {
|
|
|
4229
4379
|
if (existing) return { sendId: String(existing._id) };
|
|
4230
4380
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
4231
4381
|
const sendId = new mongodb.ObjectId();
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4382
|
+
try {
|
|
4383
|
+
await this.collections.sends.insertOne({
|
|
4384
|
+
_id: sendId,
|
|
4385
|
+
dedupeKey,
|
|
4386
|
+
externalId: parsed.externalId,
|
|
4387
|
+
emailAtSend: contact.email,
|
|
4388
|
+
templateId: template._id,
|
|
4389
|
+
templateSlug: template.slug,
|
|
4390
|
+
flowRunId: null,
|
|
4391
|
+
broadcastId: null,
|
|
4392
|
+
manualSendBy: "sendOneOff",
|
|
4393
|
+
kind: template.kind,
|
|
4394
|
+
provider: providerName,
|
|
4395
|
+
providerMessageId: null,
|
|
4396
|
+
fromName: template.fromName,
|
|
4397
|
+
fromEmail: template.fromEmail,
|
|
4398
|
+
subject: template.subject,
|
|
4399
|
+
bodyHash: "",
|
|
4400
|
+
status: "queued",
|
|
4401
|
+
errorMessage: null,
|
|
4402
|
+
bounceType: null,
|
|
4403
|
+
bounceReason: null,
|
|
4404
|
+
links: [],
|
|
4405
|
+
vars: parsed.vars ?? {},
|
|
4406
|
+
openedAt: null,
|
|
4407
|
+
openCount: 0,
|
|
4408
|
+
firstClickAt: null,
|
|
4409
|
+
clickCount: 0,
|
|
4410
|
+
clickedLinks: [],
|
|
4411
|
+
unsubscribedAt: null,
|
|
4412
|
+
complainedAt: null,
|
|
4413
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
4414
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
4415
|
+
sentAt: null,
|
|
4416
|
+
deliveredAt: null
|
|
4417
|
+
});
|
|
4418
|
+
} catch (err) {
|
|
4419
|
+
if (err?.code !== 11e3) throw err;
|
|
4420
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
4421
|
+
if (winner) return { sendId: String(winner._id) };
|
|
4422
|
+
throw err;
|
|
4423
|
+
}
|
|
4267
4424
|
await this.queues.send.add(
|
|
4268
4425
|
"send",
|
|
4269
4426
|
{ sendId: String(sendId) },
|
|
@@ -4307,7 +4464,12 @@ var Mailer = class _Mailer {
|
|
|
4307
4464
|
await runTick(this.runnerContext);
|
|
4308
4465
|
},
|
|
4309
4466
|
advance: async (data) => {
|
|
4310
|
-
if (
|
|
4467
|
+
if (data.broadcastId) {
|
|
4468
|
+
if (!mongodb.ObjectId.isValid(data.broadcastId)) return;
|
|
4469
|
+
await dispatchBroadcastById(new mongodb.ObjectId(data.broadcastId), this.runnerContext);
|
|
4470
|
+
return;
|
|
4471
|
+
}
|
|
4472
|
+
if (!data.flowRunId || !mongodb.ObjectId.isValid(data.flowRunId)) return;
|
|
4311
4473
|
await processOneRunStep(new mongodb.ObjectId(data.flowRunId), this.runnerContext);
|
|
4312
4474
|
},
|
|
4313
4475
|
send: async (data) => {
|
|
@@ -4323,27 +4485,7 @@ var Mailer = class _Mailer {
|
|
|
4323
4485
|
}
|
|
4324
4486
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
4325
4487
|
async processWebhookBacklog() {
|
|
4326
|
-
|
|
4327
|
-
for (const evt of batch) {
|
|
4328
|
-
try {
|
|
4329
|
-
const normalized = evt.raw?.normalized;
|
|
4330
|
-
const details = normalized?.details ?? {};
|
|
4331
|
-
await applyWebhookEvent(
|
|
4332
|
-
{
|
|
4333
|
-
type: evt.normalizedType,
|
|
4334
|
-
providerEventId: evt.providerEventId,
|
|
4335
|
-
providerMessageId: evt.providerMessageId,
|
|
4336
|
-
email: evt.email,
|
|
4337
|
-
occurredAt: evt.occurredAt,
|
|
4338
|
-
details
|
|
4339
|
-
},
|
|
4340
|
-
this.runnerContext
|
|
4341
|
-
);
|
|
4342
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
4343
|
-
} catch (err) {
|
|
4344
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4488
|
+
await processWebhookBacklog(this.runnerContext);
|
|
4347
4489
|
}
|
|
4348
4490
|
async stop() {
|
|
4349
4491
|
await this.queueDriver.close();
|
|
@@ -7409,7 +7551,7 @@ var DEDUPE_POLICIES = [
|
|
|
7409
7551
|
];
|
|
7410
7552
|
|
|
7411
7553
|
// src/server/index.ts
|
|
7412
|
-
var VERSION = "0.
|
|
7554
|
+
var VERSION = "0.9.0" ;
|
|
7413
7555
|
|
|
7414
7556
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
7415
7557
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|