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.js
CHANGED
|
@@ -635,6 +635,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
635
635
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
636
636
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
637
637
|
{ key: { name: 1, occurredAt: -1 } },
|
|
638
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
638
639
|
{ key: { externalId: 1, name: 1 } }
|
|
639
640
|
]),
|
|
640
641
|
c.flows.createIndexes([
|
|
@@ -645,7 +646,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
645
646
|
c.flowRuns.createIndexes([
|
|
646
647
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
647
648
|
{ key: { externalId: 1, flowId: 1 } },
|
|
648
|
-
{ key: { flowId: 1, status: 1 } }
|
|
649
|
+
{ key: { flowId: 1, status: 1 } },
|
|
650
|
+
{
|
|
651
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
652
|
+
unique: true,
|
|
653
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
654
|
+
}
|
|
649
655
|
]),
|
|
650
656
|
c.templates.createIndexes([
|
|
651
657
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -873,7 +879,7 @@ var BullDriver = class _BullDriver {
|
|
|
873
879
|
bullQueues;
|
|
874
880
|
workers = null;
|
|
875
881
|
bull;
|
|
876
|
-
static async create(redisConfig) {
|
|
882
|
+
static async create(redisConfig, prefix) {
|
|
877
883
|
let bull;
|
|
878
884
|
try {
|
|
879
885
|
bull = await import('bullmq');
|
|
@@ -882,13 +888,27 @@ var BullDriver = class _BullDriver {
|
|
|
882
888
|
"mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
|
|
883
889
|
);
|
|
884
890
|
}
|
|
891
|
+
if (prefix?.includes(":")) {
|
|
892
|
+
throw new Error(
|
|
893
|
+
`mailery: queue prefix "${prefix}" must not contain ':' \u2014 BullMQ uses it as the Redis key separator.`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
885
896
|
const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
|
|
886
|
-
return new _BullDriver(bull, redis);
|
|
897
|
+
return new _BullDriver(bull, redis, prefix);
|
|
887
898
|
}
|
|
888
|
-
|
|
899
|
+
prefix;
|
|
900
|
+
constructor(bull, redis, prefix) {
|
|
889
901
|
this.bull = bull;
|
|
890
902
|
this.redis = redis;
|
|
891
|
-
|
|
903
|
+
this.prefix = prefix;
|
|
904
|
+
const opts = {
|
|
905
|
+
connection: redis,
|
|
906
|
+
prefix,
|
|
907
|
+
defaultJobOptions: {
|
|
908
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
909
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
910
|
+
}
|
|
911
|
+
};
|
|
892
912
|
this.bullQueues = {
|
|
893
913
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
894
914
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -906,12 +926,16 @@ var BullDriver = class _BullDriver {
|
|
|
906
926
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
907
927
|
"mailer-tick-repeat",
|
|
908
928
|
{ every: intervalSeconds * 1e3 },
|
|
909
|
-
{
|
|
929
|
+
{
|
|
930
|
+
name: "tick",
|
|
931
|
+
data: {},
|
|
932
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
933
|
+
}
|
|
910
934
|
);
|
|
911
935
|
}
|
|
912
936
|
async startWorkers(opts) {
|
|
913
937
|
if (this.workers) return;
|
|
914
|
-
const base = { connection: this.redis };
|
|
938
|
+
const base = { connection: this.redis, prefix: this.prefix };
|
|
915
939
|
const { Worker } = this.bull;
|
|
916
940
|
const tick = new Worker(
|
|
917
941
|
QUEUE_NAMES.tick,
|
|
@@ -1016,9 +1040,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1016
1040
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
1017
1041
|
);
|
|
1018
1042
|
}
|
|
1043
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
1019
1044
|
const backend = new backendMod.MongoBackend({
|
|
1020
1045
|
mongo: opts.db,
|
|
1021
|
-
collection:
|
|
1046
|
+
collection: collectionName
|
|
1022
1047
|
});
|
|
1023
1048
|
const agenda = new agendaMod.Agenda({
|
|
1024
1049
|
backend,
|
|
@@ -1027,13 +1052,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1027
1052
|
maxConcurrency: 50,
|
|
1028
1053
|
defaultConcurrency: 5
|
|
1029
1054
|
});
|
|
1030
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
1055
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
1031
1056
|
}
|
|
1032
1057
|
db;
|
|
1033
|
-
|
|
1058
|
+
collName;
|
|
1059
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
1034
1060
|
this.agenda = agenda;
|
|
1035
1061
|
this.agendaMod = agendaMod;
|
|
1036
1062
|
this.db = db;
|
|
1063
|
+
this.collName = collectionName;
|
|
1037
1064
|
this.queues = {
|
|
1038
1065
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
1039
1066
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -1066,10 +1093,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1066
1093
|
}
|
|
1067
1094
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
1068
1095
|
jobsCollection() {
|
|
1069
|
-
return this.db.collection(this.
|
|
1070
|
-
}
|
|
1071
|
-
collectionName() {
|
|
1072
|
-
return "_mailerJobs";
|
|
1096
|
+
return this.db.collection(this.collName);
|
|
1073
1097
|
}
|
|
1074
1098
|
async findPending(name, jobId) {
|
|
1075
1099
|
return this.jobsCollection().findOne({
|
|
@@ -1079,12 +1103,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1079
1103
|
});
|
|
1080
1104
|
}
|
|
1081
1105
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
1082
|
-
if (!this.started) {
|
|
1083
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
1084
|
-
}, { concurrency: 1 });
|
|
1085
|
-
await this.agenda.start();
|
|
1086
|
-
this.started = true;
|
|
1087
|
-
}
|
|
1088
1106
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
1089
1107
|
}
|
|
1090
1108
|
async startWorkers(opts) {
|
|
@@ -1169,7 +1187,7 @@ var NoopDriver = class {
|
|
|
1169
1187
|
async function createQueueDriver(config, fallbackDb) {
|
|
1170
1188
|
switch (config.driver) {
|
|
1171
1189
|
case "bull":
|
|
1172
|
-
return BullDriver.create(config.redis);
|
|
1190
|
+
return BullDriver.create(config.redis, config.prefix);
|
|
1173
1191
|
case "agenda":
|
|
1174
1192
|
return AgendaDriver.create({
|
|
1175
1193
|
db: config.db ?? fallbackDb,
|
|
@@ -1187,6 +1205,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
1187
1205
|
|
|
1188
1206
|
// src/server/runner/triggers.ts
|
|
1189
1207
|
var BATCH_SIZE = 1e3;
|
|
1208
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
1190
1209
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
1191
1210
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
1192
1211
|
for (const flow of flows) {
|
|
@@ -1197,16 +1216,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
1197
1216
|
const eventName = flow.trigger.eventName;
|
|
1198
1217
|
if (!eventName) return;
|
|
1199
1218
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
1200
|
-
const
|
|
1219
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
1220
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
1201
1221
|
if (events.length === 0) return;
|
|
1202
1222
|
for (const event of events) {
|
|
1203
1223
|
await tryEnterFlow(flow, event, ctx);
|
|
1204
1224
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1225
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
1226
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
1227
|
+
await ctx.collections.flows.updateOne(
|
|
1228
|
+
{ _id: flow._id },
|
|
1229
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1210
1232
|
}
|
|
1211
1233
|
async function tryEnterFlow(flow, event, ctx) {
|
|
1212
1234
|
if (flow.trigger.once) {
|
|
@@ -1218,185 +1240,559 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1218
1240
|
}
|
|
1219
1241
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
1220
1242
|
if (!sub || sub.status !== "subscribed") return;
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1243
|
+
let result;
|
|
1244
|
+
try {
|
|
1245
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
1246
|
+
externalId: event.externalId,
|
|
1247
|
+
flowId: flow._id,
|
|
1248
|
+
flowSlug: flow.slug,
|
|
1249
|
+
flowVersion: flow.version,
|
|
1250
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
1251
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1252
|
+
triggerDedupeKey: event.dedupeKey,
|
|
1253
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
1254
|
+
status: "active",
|
|
1255
|
+
currentStepIndex: 0,
|
|
1256
|
+
currentBranchPath: [],
|
|
1257
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
1258
|
+
attemptsForCurrentStep: 0,
|
|
1259
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
1260
|
+
exitedAt: null,
|
|
1261
|
+
exitReason: null,
|
|
1262
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1263
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1264
|
+
});
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
if (err?.code !== 11e3) throw err;
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1240
1269
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
1241
1270
|
}
|
|
1242
1271
|
|
|
1243
|
-
// src/server/
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
const
|
|
1247
|
-
if (
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1254
|
-
}
|
|
1255
|
-
if ("subscriptionStatus" in p) {
|
|
1256
|
-
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1257
|
-
return sub?.status === p.subscriptionStatus;
|
|
1258
|
-
}
|
|
1259
|
-
if ("hasFiredEvent" in p) {
|
|
1260
|
-
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1261
|
-
}
|
|
1262
|
-
if ("notHasFiredEvent" in p) {
|
|
1263
|
-
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1264
|
-
}
|
|
1265
|
-
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1266
|
-
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1267
|
-
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1268
|
-
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1269
|
-
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1270
|
-
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1271
|
-
if ("all" in p) {
|
|
1272
|
-
for (const sub of p.all) {
|
|
1273
|
-
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1274
|
-
}
|
|
1275
|
-
return true;
|
|
1276
|
-
}
|
|
1277
|
-
if ("any" in p) {
|
|
1278
|
-
for (const sub of p.any) {
|
|
1279
|
-
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1280
|
-
}
|
|
1281
|
-
return false;
|
|
1282
|
-
}
|
|
1283
|
-
if ("not" in p) {
|
|
1284
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
1272
|
+
// src/server/templates/sender-domain.ts
|
|
1273
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1274
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1275
|
+
const domain = extractDomain(fromEmail);
|
|
1276
|
+
if (!domain) {
|
|
1277
|
+
return {
|
|
1278
|
+
ok: false,
|
|
1279
|
+
code: "invalid_email",
|
|
1280
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1281
|
+
};
|
|
1285
1282
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
}
|
|
1295
|
-
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1296
|
-
const filter = { externalId: ctx.contact.externalId };
|
|
1297
|
-
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1298
|
-
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1299
|
-
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1300
|
-
const lower = effectiveLowerBound(ctx, opts);
|
|
1301
|
-
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1302
|
-
if (!excludeBots) {
|
|
1303
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
1283
|
+
const entry = registry[domain];
|
|
1284
|
+
if (!entry) {
|
|
1285
|
+
const known = Object.keys(registry).join(", ");
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
code: "unregistered_domain",
|
|
1289
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1290
|
+
};
|
|
1304
1291
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
}
|
|
1312
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1313
|
-
if (hasHumanClick) n++;
|
|
1292
|
+
if (entry.kind === "both") return { ok: true };
|
|
1293
|
+
if (entry.kind !== templateKind) {
|
|
1294
|
+
return {
|
|
1295
|
+
ok: false,
|
|
1296
|
+
code: "wrong_kind",
|
|
1297
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1298
|
+
};
|
|
1314
1299
|
}
|
|
1315
|
-
return
|
|
1300
|
+
return { ok: true };
|
|
1316
1301
|
}
|
|
1317
|
-
function
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
return null;
|
|
1302
|
+
function extractDomain(email) {
|
|
1303
|
+
if (typeof email !== "string") return null;
|
|
1304
|
+
const at = email.lastIndexOf("@");
|
|
1305
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
1306
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
1324
1307
|
}
|
|
1325
1308
|
|
|
1326
|
-
// src/server/runner/
|
|
1327
|
-
var
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1309
|
+
// src/server/runner/health.ts
|
|
1310
|
+
var ZERO_COUNTERS = {
|
|
1311
|
+
sent: 0,
|
|
1312
|
+
delivered: 0,
|
|
1313
|
+
bounced: 0,
|
|
1314
|
+
hardBounced: 0,
|
|
1315
|
+
softBounced: 0,
|
|
1316
|
+
complained: 0,
|
|
1317
|
+
failedToSend: 0
|
|
1318
|
+
};
|
|
1319
|
+
var ZERO_RATES = {
|
|
1320
|
+
bounceRate: 0,
|
|
1321
|
+
hardBounceRate: 0,
|
|
1322
|
+
complaintRate: 0,
|
|
1323
|
+
failureRate: 0
|
|
1324
|
+
};
|
|
1325
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1326
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1327
|
+
const writes = [];
|
|
1328
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1329
|
+
if (dims) {
|
|
1330
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1331
|
+
if (domain) {
|
|
1332
|
+
const id = healthBucketId(domain, dims.kind);
|
|
1333
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1349
1334
|
}
|
|
1350
1335
|
}
|
|
1351
|
-
|
|
1352
|
-
}
|
|
1353
|
-
function pickTimezone(window, contactTimezone) {
|
|
1354
|
-
const candidates = [
|
|
1355
|
-
window.useContactTimezone ? contactTimezone : void 0,
|
|
1356
|
-
window.timezone,
|
|
1357
|
-
"UTC"
|
|
1358
|
-
];
|
|
1359
|
-
for (const tz of candidates) {
|
|
1360
|
-
if (tz && isValidTimezone(tz)) return tz;
|
|
1361
|
-
}
|
|
1362
|
-
return "UTC";
|
|
1336
|
+
await Promise.all(writes);
|
|
1363
1337
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1338
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1339
|
+
await ctx.collections.health.updateOne(
|
|
1340
|
+
{ _id },
|
|
1341
|
+
{
|
|
1342
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
1343
|
+
$setOnInsert: {
|
|
1344
|
+
_id,
|
|
1345
|
+
senderDomain,
|
|
1346
|
+
kind,
|
|
1347
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1348
|
+
windowDurationMs: windowMs,
|
|
1349
|
+
status: "healthy",
|
|
1350
|
+
trippedAt: null,
|
|
1351
|
+
trippedReason: null,
|
|
1352
|
+
manuallyResumedAt: null,
|
|
1353
|
+
rates: { ...ZERO_RATES }
|
|
1354
|
+
},
|
|
1355
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1356
|
+
},
|
|
1357
|
+
{ upsert: true }
|
|
1358
|
+
);
|
|
1376
1359
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
day: "2-digit",
|
|
1386
|
-
hour: "2-digit",
|
|
1387
|
-
minute: "2-digit",
|
|
1388
|
-
second: "2-digit",
|
|
1389
|
-
weekday: "short",
|
|
1390
|
-
hour12: false
|
|
1391
|
-
});
|
|
1392
|
-
partFormatters.set(tz, f);
|
|
1360
|
+
async function evaluateHealth(ctx) {
|
|
1361
|
+
const cb = ctx.config.circuitBreaker;
|
|
1362
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1363
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
1364
|
+
if (docs.length === 0) return;
|
|
1365
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1366
|
+
if (!hasAgg) {
|
|
1367
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1393
1368
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1369
|
+
for (const doc of docs) {
|
|
1370
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1371
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1372
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1373
|
+
await ctx.collections.health.updateOne(
|
|
1374
|
+
{ _id: doc._id },
|
|
1375
|
+
{
|
|
1376
|
+
$set: {
|
|
1377
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1378
|
+
windowDurationMs: windowMs,
|
|
1379
|
+
counters: { ...ZERO_COUNTERS },
|
|
1380
|
+
rates: { ...ZERO_RATES },
|
|
1381
|
+
status: "healthy",
|
|
1382
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
);
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
const c = doc.counters;
|
|
1389
|
+
const total = c.sent || 1;
|
|
1390
|
+
const rates = {
|
|
1391
|
+
bounceRate: c.bounced / total,
|
|
1392
|
+
hardBounceRate: c.hardBounced / total,
|
|
1393
|
+
complaintRate: c.complained / total,
|
|
1394
|
+
failureRate: c.failedToSend / total
|
|
1395
|
+
};
|
|
1396
|
+
await ctx.collections.health.updateOne(
|
|
1397
|
+
{ _id: doc._id },
|
|
1398
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1399
|
+
);
|
|
1400
|
+
if (isAgg) continue;
|
|
1401
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1402
|
+
if (doc.status === "tripped") continue;
|
|
1403
|
+
let trippedReason = null;
|
|
1404
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1405
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1406
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1407
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1408
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1409
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1410
|
+
}
|
|
1411
|
+
if (trippedReason) {
|
|
1412
|
+
const result = await ctx.collections.health.updateOne(
|
|
1413
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1414
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1415
|
+
);
|
|
1416
|
+
if (result.modifiedCount > 0) {
|
|
1417
|
+
if (ctx.audit) {
|
|
1418
|
+
try {
|
|
1419
|
+
await ctx.audit({
|
|
1420
|
+
actor: "system:circuit-breaker",
|
|
1421
|
+
action: "health.trip",
|
|
1422
|
+
resource: {
|
|
1423
|
+
collection: "mailer_health",
|
|
1424
|
+
id: String(doc._id),
|
|
1425
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1426
|
+
},
|
|
1427
|
+
diffSummary: trippedReason
|
|
1428
|
+
});
|
|
1429
|
+
} catch {
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
1433
|
+
try {
|
|
1434
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
1435
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1436
|
+
rates
|
|
1437
|
+
});
|
|
1438
|
+
} catch {
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1445
|
+
if (doc.status !== "degraded") {
|
|
1446
|
+
await ctx.collections.health.updateOne(
|
|
1447
|
+
{ _id: doc._id },
|
|
1448
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
} else if (doc.status === "degraded") {
|
|
1452
|
+
await ctx.collections.health.updateOne(
|
|
1453
|
+
{ _id: doc._id },
|
|
1454
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
1460
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
1461
|
+
const id = healthBucketId(domain, kind);
|
|
1462
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
1463
|
+
}
|
|
1464
|
+
function effectiveOverallStatus(docs) {
|
|
1465
|
+
const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
|
|
1466
|
+
if (buckets.length === 0) {
|
|
1467
|
+
return docs.length === 0 ? null : "healthy";
|
|
1468
|
+
}
|
|
1469
|
+
if (buckets.some((d) => d.status === "tripped")) return "tripped";
|
|
1470
|
+
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1471
|
+
return "healthy";
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/server/runner/webhook.ts
|
|
1475
|
+
function dimsFromSend(send) {
|
|
1476
|
+
if (!send) return null;
|
|
1477
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
1478
|
+
}
|
|
1479
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
1480
|
+
const filter = { processed: false };
|
|
1481
|
+
if (opts.olderThanMs) {
|
|
1482
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
1483
|
+
}
|
|
1484
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
1485
|
+
for (const evt of batch) {
|
|
1486
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
1487
|
+
{ _id: evt._id, processed: false },
|
|
1488
|
+
{ $set: { processed: true } }
|
|
1489
|
+
);
|
|
1490
|
+
if (!claimed) continue;
|
|
1491
|
+
try {
|
|
1492
|
+
const normalized = evt.raw?.normalized;
|
|
1493
|
+
const details = normalized?.details ?? {};
|
|
1494
|
+
await applyWebhookEvent(
|
|
1495
|
+
{
|
|
1496
|
+
type: evt.normalizedType,
|
|
1497
|
+
providerEventId: evt.providerEventId,
|
|
1498
|
+
providerMessageId: evt.providerMessageId,
|
|
1499
|
+
email: evt.email,
|
|
1500
|
+
occurredAt: evt.occurredAt,
|
|
1501
|
+
details
|
|
1502
|
+
},
|
|
1503
|
+
ctx
|
|
1504
|
+
);
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async function applyWebhookEvent(event, ctx) {
|
|
1511
|
+
const send = await ctx.collections.sends.findOne(
|
|
1512
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
1513
|
+
{ sort: { queuedAt: -1 } }
|
|
1514
|
+
);
|
|
1515
|
+
switch (event.type) {
|
|
1516
|
+
case "delivered":
|
|
1517
|
+
if (send) {
|
|
1518
|
+
await ctx.collections.sends.updateOne(
|
|
1519
|
+
{ _id: send._id },
|
|
1520
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
1524
|
+
break;
|
|
1525
|
+
case "open":
|
|
1526
|
+
if (send) {
|
|
1527
|
+
await ctx.collections.sends.updateOne(
|
|
1528
|
+
{ _id: send._id },
|
|
1529
|
+
{
|
|
1530
|
+
$set: {
|
|
1531
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
1532
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
1533
|
+
},
|
|
1534
|
+
$inc: { openCount: 1 }
|
|
1535
|
+
}
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
break;
|
|
1539
|
+
case "click":
|
|
1540
|
+
if (send) {
|
|
1541
|
+
await ctx.collections.sends.updateOne(
|
|
1542
|
+
{ _id: send._id },
|
|
1543
|
+
{
|
|
1544
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
1545
|
+
$inc: { clickCount: 1 },
|
|
1546
|
+
$push: {
|
|
1547
|
+
clickedLinks: {
|
|
1548
|
+
url: event.details.clickedUrl ?? "",
|
|
1549
|
+
linkId: "",
|
|
1550
|
+
clickedAt: event.occurredAt
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
break;
|
|
1557
|
+
case "bounce": {
|
|
1558
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
1559
|
+
if (send) {
|
|
1560
|
+
await ctx.collections.sends.updateOne(
|
|
1561
|
+
{ _id: send._id },
|
|
1562
|
+
{
|
|
1563
|
+
$set: {
|
|
1564
|
+
status: "bounced",
|
|
1565
|
+
bounceType,
|
|
1566
|
+
bounceReason: event.details.bounceReason ?? null
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
if (bounceType === "hard") {
|
|
1572
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
1573
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1574
|
+
{ emailAtSubscribe: event.email },
|
|
1575
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
{
|
|
1579
|
+
const dims = dimsFromSend(send);
|
|
1580
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
1581
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
1582
|
+
}
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
case "complaint":
|
|
1586
|
+
case "spam_report":
|
|
1587
|
+
if (send) {
|
|
1588
|
+
await ctx.collections.sends.updateOne(
|
|
1589
|
+
{ _id: send._id },
|
|
1590
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
1594
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1595
|
+
{ emailAtSubscribe: event.email },
|
|
1596
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1597
|
+
);
|
|
1598
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
1599
|
+
break;
|
|
1600
|
+
case "unsubscribe":
|
|
1601
|
+
if (send) {
|
|
1602
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
1603
|
+
}
|
|
1604
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
1605
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1606
|
+
{ emailAtSubscribe: event.email },
|
|
1607
|
+
{
|
|
1608
|
+
$set: {
|
|
1609
|
+
status: "unsubscribed",
|
|
1610
|
+
unsubscribedAt: event.occurredAt,
|
|
1611
|
+
unsubscribeReason: "user_request",
|
|
1612
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
);
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
1620
|
+
const normalized = email.toLowerCase();
|
|
1621
|
+
await ctx.collections.suppressions.updateOne(
|
|
1622
|
+
{ email: normalized, scope },
|
|
1623
|
+
{
|
|
1624
|
+
$setOnInsert: {
|
|
1625
|
+
email: normalized,
|
|
1626
|
+
emailHash: sha256Hex(normalized),
|
|
1627
|
+
scope,
|
|
1628
|
+
reason,
|
|
1629
|
+
source: "provider_webhook",
|
|
1630
|
+
notes: null,
|
|
1631
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
1632
|
+
expiresAt: null
|
|
1633
|
+
}
|
|
1634
|
+
},
|
|
1635
|
+
{ upsert: true }
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// src/server/runner/predicate.ts
|
|
1640
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1641
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
1642
|
+
const p = predicate;
|
|
1643
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
1644
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
1645
|
+
if ("fieldEquals" in p) {
|
|
1646
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
1647
|
+
}
|
|
1648
|
+
if ("fieldExists" in p) {
|
|
1649
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1650
|
+
}
|
|
1651
|
+
if ("subscriptionStatus" in p) {
|
|
1652
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1653
|
+
return sub?.status === p.subscriptionStatus;
|
|
1654
|
+
}
|
|
1655
|
+
if ("hasFiredEvent" in p) {
|
|
1656
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1657
|
+
}
|
|
1658
|
+
if ("notHasFiredEvent" in p) {
|
|
1659
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1660
|
+
}
|
|
1661
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1662
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1663
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1664
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1665
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1666
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1667
|
+
if ("all" in p) {
|
|
1668
|
+
for (const sub of p.all) {
|
|
1669
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1670
|
+
}
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
if ("any" in p) {
|
|
1674
|
+
for (const sub of p.any) {
|
|
1675
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1676
|
+
}
|
|
1677
|
+
return false;
|
|
1678
|
+
}
|
|
1679
|
+
if ("not" in p) {
|
|
1680
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
1681
|
+
}
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
async function hasEvent(ctx, name, opts) {
|
|
1685
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
1686
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1687
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
1688
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
1689
|
+
return !!found;
|
|
1690
|
+
}
|
|
1691
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1692
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
1693
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1694
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1695
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1696
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1697
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1698
|
+
if (!excludeBots) {
|
|
1699
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
1700
|
+
}
|
|
1701
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1702
|
+
let n = 0;
|
|
1703
|
+
for (const s of docs) {
|
|
1704
|
+
if (kind === "opened") {
|
|
1705
|
+
n++;
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1709
|
+
if (hasHumanClick) n++;
|
|
1710
|
+
}
|
|
1711
|
+
return n;
|
|
1712
|
+
}
|
|
1713
|
+
function effectiveLowerBound(ctx, opts) {
|
|
1714
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1715
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
1716
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
1717
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
1718
|
+
}
|
|
1719
|
+
return null;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// src/server/runner/delivery-window.ts
|
|
1723
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1724
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1725
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1726
|
+
let candidate = now;
|
|
1727
|
+
if (window.timeOfDay) {
|
|
1728
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1729
|
+
const local = localParts(candidate, tz);
|
|
1730
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1731
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1732
|
+
candidate = todaySlot;
|
|
1733
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1734
|
+
const next = addLocalDays(local, 1);
|
|
1735
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
if (window.weekdaysOnly) {
|
|
1739
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1740
|
+
const local = localParts(candidate, tz);
|
|
1741
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1742
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1743
|
+
const moved = addLocalDays(local, shift);
|
|
1744
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return candidate;
|
|
1748
|
+
}
|
|
1749
|
+
function pickTimezone(window, contactTimezone) {
|
|
1750
|
+
const candidates = [
|
|
1751
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1752
|
+
window.timezone,
|
|
1753
|
+
"UTC"
|
|
1754
|
+
];
|
|
1755
|
+
for (const tz of candidates) {
|
|
1756
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1757
|
+
}
|
|
1758
|
+
return "UTC";
|
|
1759
|
+
}
|
|
1760
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1761
|
+
function isValidTimezone(tz) {
|
|
1762
|
+
const cached = validatedZones.get(tz);
|
|
1763
|
+
if (cached !== void 0) return cached;
|
|
1764
|
+
let ok = true;
|
|
1765
|
+
try {
|
|
1766
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1767
|
+
} catch {
|
|
1768
|
+
ok = false;
|
|
1769
|
+
}
|
|
1770
|
+
validatedZones.set(tz, ok);
|
|
1771
|
+
return ok;
|
|
1772
|
+
}
|
|
1773
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1774
|
+
function formatterFor(tz) {
|
|
1775
|
+
let f = partFormatters.get(tz);
|
|
1776
|
+
if (!f) {
|
|
1777
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1778
|
+
timeZone: tz,
|
|
1779
|
+
year: "numeric",
|
|
1780
|
+
month: "2-digit",
|
|
1781
|
+
day: "2-digit",
|
|
1782
|
+
hour: "2-digit",
|
|
1783
|
+
minute: "2-digit",
|
|
1784
|
+
second: "2-digit",
|
|
1785
|
+
weekday: "short",
|
|
1786
|
+
hour12: false
|
|
1787
|
+
});
|
|
1788
|
+
partFormatters.set(tz, f);
|
|
1789
|
+
}
|
|
1790
|
+
return f;
|
|
1791
|
+
}
|
|
1792
|
+
function localParts(date, tz) {
|
|
1793
|
+
const parts = {};
|
|
1794
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1795
|
+
return {
|
|
1400
1796
|
y: Number(parts.year),
|
|
1401
1797
|
mo: Number(parts.month),
|
|
1402
1798
|
d: Number(parts.day),
|
|
@@ -1469,7 +1865,8 @@ function applyTracking(html, opts) {
|
|
|
1469
1865
|
const seen = /* @__PURE__ */ new Map();
|
|
1470
1866
|
let out = html;
|
|
1471
1867
|
if (opts.trackClicks) {
|
|
1472
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
1868
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
1869
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
1473
1870
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
1474
1871
|
let linkId = seen.get(url);
|
|
1475
1872
|
if (!linkId) {
|
|
@@ -1501,6 +1898,29 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
1501
1898
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
1502
1899
|
return false;
|
|
1503
1900
|
}
|
|
1901
|
+
var NAMED_ENTITIES = {
|
|
1902
|
+
amp: "&",
|
|
1903
|
+
lt: "<",
|
|
1904
|
+
gt: ">",
|
|
1905
|
+
quot: '"',
|
|
1906
|
+
apos: "'",
|
|
1907
|
+
nbsp: "\xA0"
|
|
1908
|
+
};
|
|
1909
|
+
function decodeHtmlEntities(input) {
|
|
1910
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
1911
|
+
if (body[0] === "#") {
|
|
1912
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
1913
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
1914
|
+
try {
|
|
1915
|
+
return String.fromCodePoint(code);
|
|
1916
|
+
} catch {
|
|
1917
|
+
return match;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
1921
|
+
return named ?? match;
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1504
1924
|
function shortHash(input) {
|
|
1505
1925
|
return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
1506
1926
|
}
|
|
@@ -1509,266 +1929,64 @@ function makeHandlebars(extra) {
|
|
|
1509
1929
|
hb.registerHelper("eq", (a, b) => a === b);
|
|
1510
1930
|
hb.registerHelper("ne", (a, b) => a !== b);
|
|
1511
1931
|
hb.registerHelper("gt", (a, b) => a > b);
|
|
1512
|
-
hb.registerHelper("lt", (a, b) => a < b);
|
|
1513
|
-
hb.registerHelper("gte", (a, b) => a >= b);
|
|
1514
|
-
hb.registerHelper("lte", (a, b) => a <= b);
|
|
1515
|
-
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
1516
|
-
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1517
|
-
hb.registerHelper("not", (a) => !a);
|
|
1518
|
-
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1519
|
-
if (!value) return "";
|
|
1520
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
1521
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
1522
|
-
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1523
|
-
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1524
|
-
return d.toISOString().slice(0, 10);
|
|
1525
|
-
});
|
|
1526
|
-
hb.registerHelper("formatNumber", (n) => {
|
|
1527
|
-
const v = Number(n);
|
|
1528
|
-
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1529
|
-
});
|
|
1530
|
-
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1531
|
-
const v = Number(cents);
|
|
1532
|
-
if (!Number.isFinite(v)) return "";
|
|
1533
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1534
|
-
});
|
|
1535
|
-
hb.registerHelper(
|
|
1536
|
-
"pluralize",
|
|
1537
|
-
(n, one, many) => Number(n) === 1 ? one : many
|
|
1538
|
-
);
|
|
1539
|
-
if (extra) {
|
|
1540
|
-
for (const [name, fn] of Object.entries(extra)) {
|
|
1541
|
-
hb.registerHelper(name, fn);
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
return hb;
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
// src/server/runner/send.ts
|
|
1548
|
-
init_vars();
|
|
1549
|
-
|
|
1550
|
-
// src/server/runner/suppression.ts
|
|
1551
|
-
var SCOPES_BY_KIND = {
|
|
1552
|
-
marketing: ["all", "marketing"],
|
|
1553
|
-
transactional: ["all", "transactional"]
|
|
1554
|
-
};
|
|
1555
|
-
async function isSuppressed(collections, email, kind) {
|
|
1556
|
-
const normalized = email.toLowerCase();
|
|
1557
|
-
const allowed = SCOPES_BY_KIND[kind];
|
|
1558
|
-
const byEmail = await collections.suppressions.findOne({
|
|
1559
|
-
email: normalized,
|
|
1560
|
-
scope: { $in: allowed },
|
|
1561
|
-
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
1562
|
-
});
|
|
1563
|
-
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
1564
|
-
const hashed = await collections.suppressions.findOne({
|
|
1565
|
-
emailHash: sha256Hex(normalized),
|
|
1566
|
-
scope: { $in: allowed }
|
|
1567
|
-
});
|
|
1568
|
-
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
1569
|
-
return { suppressed: false };
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
// src/server/templates/sender-domain.ts
|
|
1573
|
-
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1574
|
-
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1575
|
-
const domain = extractDomain(fromEmail);
|
|
1576
|
-
if (!domain) {
|
|
1577
|
-
return {
|
|
1578
|
-
ok: false,
|
|
1579
|
-
code: "invalid_email",
|
|
1580
|
-
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1581
|
-
};
|
|
1582
|
-
}
|
|
1583
|
-
const entry = registry[domain];
|
|
1584
|
-
if (!entry) {
|
|
1585
|
-
const known = Object.keys(registry).join(", ");
|
|
1586
|
-
return {
|
|
1587
|
-
ok: false,
|
|
1588
|
-
code: "unregistered_domain",
|
|
1589
|
-
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1590
|
-
};
|
|
1591
|
-
}
|
|
1592
|
-
if (entry.kind === "both") return { ok: true };
|
|
1593
|
-
if (entry.kind !== templateKind) {
|
|
1594
|
-
return {
|
|
1595
|
-
ok: false,
|
|
1596
|
-
code: "wrong_kind",
|
|
1597
|
-
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1598
|
-
};
|
|
1599
|
-
}
|
|
1600
|
-
return { ok: true };
|
|
1601
|
-
}
|
|
1602
|
-
function extractDomain(email) {
|
|
1603
|
-
if (typeof email !== "string") return null;
|
|
1604
|
-
const at = email.lastIndexOf("@");
|
|
1605
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
1606
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
// src/server/runner/health.ts
|
|
1610
|
-
var ZERO_COUNTERS = {
|
|
1611
|
-
sent: 0,
|
|
1612
|
-
delivered: 0,
|
|
1613
|
-
bounced: 0,
|
|
1614
|
-
hardBounced: 0,
|
|
1615
|
-
softBounced: 0,
|
|
1616
|
-
complained: 0,
|
|
1617
|
-
failedToSend: 0
|
|
1618
|
-
};
|
|
1619
|
-
var ZERO_RATES = {
|
|
1620
|
-
bounceRate: 0,
|
|
1621
|
-
hardBounceRate: 0,
|
|
1622
|
-
complaintRate: 0,
|
|
1623
|
-
failureRate: 0
|
|
1624
|
-
};
|
|
1625
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1626
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1627
|
-
const writes = [];
|
|
1628
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1629
|
-
if (dims) {
|
|
1630
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1631
|
-
if (domain) {
|
|
1632
|
-
const id = healthBucketId(domain, dims.kind);
|
|
1633
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
await Promise.all(writes);
|
|
1637
|
-
}
|
|
1638
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1639
|
-
await ctx.collections.health.updateOne(
|
|
1640
|
-
{ _id },
|
|
1641
|
-
{
|
|
1642
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
1643
|
-
$setOnInsert: {
|
|
1644
|
-
_id,
|
|
1645
|
-
senderDomain,
|
|
1646
|
-
kind,
|
|
1647
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1648
|
-
windowDurationMs: windowMs,
|
|
1649
|
-
status: "healthy",
|
|
1650
|
-
trippedAt: null,
|
|
1651
|
-
trippedReason: null,
|
|
1652
|
-
manuallyResumedAt: null,
|
|
1653
|
-
rates: { ...ZERO_RATES }
|
|
1654
|
-
},
|
|
1655
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1656
|
-
},
|
|
1657
|
-
{ upsert: true }
|
|
1658
|
-
);
|
|
1659
|
-
}
|
|
1660
|
-
async function evaluateHealth(ctx) {
|
|
1661
|
-
const cb = ctx.config.circuitBreaker;
|
|
1662
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1663
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
1664
|
-
if (docs.length === 0) return;
|
|
1665
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1666
|
-
if (!hasAgg) {
|
|
1667
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1668
|
-
}
|
|
1669
|
-
for (const doc of docs) {
|
|
1670
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1671
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1672
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1673
|
-
await ctx.collections.health.updateOne(
|
|
1674
|
-
{ _id: doc._id },
|
|
1675
|
-
{
|
|
1676
|
-
$set: {
|
|
1677
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1678
|
-
windowDurationMs: windowMs,
|
|
1679
|
-
counters: { ...ZERO_COUNTERS },
|
|
1680
|
-
rates: { ...ZERO_RATES },
|
|
1681
|
-
status: "healthy",
|
|
1682
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
);
|
|
1686
|
-
continue;
|
|
1687
|
-
}
|
|
1688
|
-
const c = doc.counters;
|
|
1689
|
-
const total = c.sent || 1;
|
|
1690
|
-
const rates = {
|
|
1691
|
-
bounceRate: c.bounced / total,
|
|
1692
|
-
hardBounceRate: c.hardBounced / total,
|
|
1693
|
-
complaintRate: c.complained / total,
|
|
1694
|
-
failureRate: c.failedToSend / total
|
|
1695
|
-
};
|
|
1696
|
-
await ctx.collections.health.updateOne(
|
|
1697
|
-
{ _id: doc._id },
|
|
1698
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1699
|
-
);
|
|
1700
|
-
if (isAgg) continue;
|
|
1701
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1702
|
-
if (doc.status === "tripped") continue;
|
|
1703
|
-
let trippedReason = null;
|
|
1704
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1705
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1706
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1707
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1708
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1709
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1710
|
-
}
|
|
1711
|
-
if (trippedReason) {
|
|
1712
|
-
const result = await ctx.collections.health.updateOne(
|
|
1713
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1714
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1715
|
-
);
|
|
1716
|
-
if (result.modifiedCount > 0) {
|
|
1717
|
-
if (ctx.audit) {
|
|
1718
|
-
try {
|
|
1719
|
-
await ctx.audit({
|
|
1720
|
-
actor: "system:circuit-breaker",
|
|
1721
|
-
action: "health.trip",
|
|
1722
|
-
resource: {
|
|
1723
|
-
collection: "mailer_health",
|
|
1724
|
-
id: String(doc._id),
|
|
1725
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1726
|
-
},
|
|
1727
|
-
diffSummary: trippedReason
|
|
1728
|
-
});
|
|
1729
|
-
} catch {
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
1733
|
-
try {
|
|
1734
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
1735
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1736
|
-
rates
|
|
1737
|
-
});
|
|
1738
|
-
} catch {
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
continue;
|
|
1743
|
-
}
|
|
1744
|
-
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1745
|
-
if (doc.status !== "degraded") {
|
|
1746
|
-
await ctx.collections.health.updateOne(
|
|
1747
|
-
{ _id: doc._id },
|
|
1748
|
-
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1749
|
-
);
|
|
1750
|
-
}
|
|
1751
|
-
} else if (doc.status === "degraded") {
|
|
1752
|
-
await ctx.collections.health.updateOne(
|
|
1753
|
-
{ _id: doc._id },
|
|
1754
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1755
|
-
);
|
|
1932
|
+
hb.registerHelper("lt", (a, b) => a < b);
|
|
1933
|
+
hb.registerHelper("gte", (a, b) => a >= b);
|
|
1934
|
+
hb.registerHelper("lte", (a, b) => a <= b);
|
|
1935
|
+
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
1936
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1937
|
+
hb.registerHelper("not", (a) => !a);
|
|
1938
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1939
|
+
if (!value) return "";
|
|
1940
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
1941
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
1942
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1943
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1944
|
+
return d.toISOString().slice(0, 10);
|
|
1945
|
+
});
|
|
1946
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
1947
|
+
const v = Number(n);
|
|
1948
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1949
|
+
});
|
|
1950
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1951
|
+
const v = Number(cents);
|
|
1952
|
+
if (!Number.isFinite(v)) return "";
|
|
1953
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1954
|
+
});
|
|
1955
|
+
hb.registerHelper(
|
|
1956
|
+
"pluralize",
|
|
1957
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
1958
|
+
);
|
|
1959
|
+
if (extra) {
|
|
1960
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
1961
|
+
hb.registerHelper(name, fn);
|
|
1756
1962
|
}
|
|
1757
1963
|
}
|
|
1964
|
+
return hb;
|
|
1758
1965
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1966
|
+
|
|
1967
|
+
// src/server/runner/send.ts
|
|
1968
|
+
init_vars();
|
|
1969
|
+
|
|
1970
|
+
// src/server/runner/suppression.ts
|
|
1971
|
+
var SCOPES_BY_KIND = {
|
|
1972
|
+
marketing: ["all", "marketing"],
|
|
1973
|
+
transactional: ["all", "transactional"]
|
|
1974
|
+
};
|
|
1975
|
+
async function isSuppressed(collections, email, kind) {
|
|
1976
|
+
const normalized = email.toLowerCase();
|
|
1977
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
1978
|
+
const byEmail = await collections.suppressions.findOne({
|
|
1979
|
+
email: normalized,
|
|
1980
|
+
scope: { $in: allowed },
|
|
1981
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
1982
|
+
});
|
|
1983
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
1984
|
+
const hashed = await collections.suppressions.findOne({
|
|
1985
|
+
emailHash: sha256Hex(normalized),
|
|
1986
|
+
scope: { $in: allowed }
|
|
1987
|
+
});
|
|
1988
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
1989
|
+
return { suppressed: false };
|
|
1772
1990
|
}
|
|
1773
1991
|
|
|
1774
1992
|
// src/server/runner/send.ts
|
|
@@ -1836,9 +2054,11 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
1836
2054
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
1837
2055
|
}
|
|
1838
2056
|
async function dispatchSend(sendId, ctx) {
|
|
1839
|
-
const send = await ctx.collections.sends.
|
|
2057
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
2058
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
2059
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2060
|
+
);
|
|
1840
2061
|
if (!send) return;
|
|
1841
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
1842
2062
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
1843
2063
|
if (!template) {
|
|
1844
2064
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -1855,6 +2075,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1855
2075
|
if (send.kind === "marketing") {
|
|
1856
2076
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
1857
2077
|
if (bucket?.status === "tripped") {
|
|
2078
|
+
await ctx.collections.sends.updateOne(
|
|
2079
|
+
{ _id: send._id },
|
|
2080
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2081
|
+
);
|
|
1858
2082
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
1859
2083
|
return;
|
|
1860
2084
|
}
|
|
@@ -2103,7 +2327,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2103
2327
|
await ctx.queues.advance.add(
|
|
2104
2328
|
"advance",
|
|
2105
2329
|
{ flowRunId: String(run._id) },
|
|
2106
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
2330
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
2107
2331
|
);
|
|
2108
2332
|
}
|
|
2109
2333
|
async function handleCondition(run, step, contact, ctx) {
|
|
@@ -2215,7 +2439,7 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
2215
2439
|
await ctx.queues.advance.add(
|
|
2216
2440
|
"advance",
|
|
2217
2441
|
{ flowRunId: String(run._id) },
|
|
2218
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2442
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2219
2443
|
);
|
|
2220
2444
|
}
|
|
2221
2445
|
}
|
|
@@ -2244,7 +2468,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
2244
2468
|
{ flowRunId: String(run._id) },
|
|
2245
2469
|
{
|
|
2246
2470
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2247
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2471
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2248
2472
|
}
|
|
2249
2473
|
);
|
|
2250
2474
|
}
|
|
@@ -2299,13 +2523,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
2299
2523
|
let arr = steps;
|
|
2300
2524
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
2301
2525
|
const parentIndex = branchPath[i];
|
|
2302
|
-
const
|
|
2526
|
+
const branchKey2 = branchPath[i + 1];
|
|
2303
2527
|
const parent = arr[parentIndex];
|
|
2304
2528
|
if (!parent || parent.type !== "branch") return null;
|
|
2305
|
-
arr =
|
|
2529
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
2306
2530
|
}
|
|
2307
2531
|
return arr[currentStepIndex] ?? null;
|
|
2308
2532
|
}
|
|
2533
|
+
function branchKey(run) {
|
|
2534
|
+
return run.currentBranchPath.join("_");
|
|
2535
|
+
}
|
|
2309
2536
|
function unitToMs(value, unit) {
|
|
2310
2537
|
const m = 6e4;
|
|
2311
2538
|
switch (unit) {
|
|
@@ -2332,6 +2559,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
2332
2559
|
}
|
|
2333
2560
|
}
|
|
2334
2561
|
}
|
|
2562
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
2335
2563
|
async function processScheduledBroadcasts(ctx) {
|
|
2336
2564
|
const now = /* @__PURE__ */ new Date();
|
|
2337
2565
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -2342,15 +2570,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2342
2570
|
{ returnDocument: "after" }
|
|
2343
2571
|
);
|
|
2344
2572
|
if (!claimed) continue;
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2573
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
2577
|
+
if (ctx.config.queue.driver === "noop") {
|
|
2578
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
await ctx.queues.advance.add(
|
|
2582
|
+
"advance",
|
|
2583
|
+
{ broadcastId: String(broadcast._id) },
|
|
2584
|
+
{
|
|
2585
|
+
attempts: 3,
|
|
2586
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
2587
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
2353
2588
|
}
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
2592
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
2593
|
+
if (!broadcast) return;
|
|
2594
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2595
|
+
}
|
|
2596
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
2597
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
2598
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
2599
|
+
for (const b of stalled) {
|
|
2600
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2601
|
+
{ _id: b._id },
|
|
2602
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2603
|
+
);
|
|
2604
|
+
await startBroadcastDispatch(b, ctx);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
2608
|
+
try {
|
|
2609
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
2610
|
+
} catch (err) {
|
|
2611
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
2612
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2613
|
+
{ _id: broadcast._id },
|
|
2614
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2615
|
+
);
|
|
2354
2616
|
}
|
|
2355
2617
|
}
|
|
2356
2618
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -2371,11 +2633,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
2371
2633
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
2372
2634
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
2373
2635
|
for (; ; ) {
|
|
2636
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2637
|
+
{ _id: broadcast._id },
|
|
2638
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2639
|
+
);
|
|
2374
2640
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
2375
2641
|
if (page.contacts.length === 0) break;
|
|
2376
2642
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
2377
2643
|
if (eligible.length > 0) {
|
|
2378
2644
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
2645
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2646
|
+
{ _id: broadcast._id },
|
|
2647
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2648
|
+
);
|
|
2379
2649
|
await sleep(2e3);
|
|
2380
2650
|
}
|
|
2381
2651
|
const sendDocs = await Promise.all(
|
|
@@ -2498,7 +2768,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2498
2768
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
2499
2769
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
2500
2770
|
if (respectTimezone && contact.timezone) {
|
|
2501
|
-
|
|
2771
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
2772
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
2502
2773
|
}
|
|
2503
2774
|
const doc = {
|
|
2504
2775
|
_id: sendId,
|
|
@@ -2537,7 +2808,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2537
2808
|
};
|
|
2538
2809
|
return { doc, delayMs };
|
|
2539
2810
|
}
|
|
2540
|
-
function
|
|
2811
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
2812
|
+
const DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2541
2813
|
try {
|
|
2542
2814
|
const scheduled = new Date(scheduledMs);
|
|
2543
2815
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -2550,7 +2822,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
2550
2822
|
const utcMs = parse(utc);
|
|
2551
2823
|
const localMs = parse(local);
|
|
2552
2824
|
const offsetMs = utcMs - localMs;
|
|
2553
|
-
return offsetMs;
|
|
2825
|
+
return (offsetMs % DAY_MS2 + DAY_MS2) % DAY_MS2;
|
|
2554
2826
|
} catch {
|
|
2555
2827
|
return 0;
|
|
2556
2828
|
}
|
|
@@ -3497,6 +3769,7 @@ function suggestPolicyProgression(input) {
|
|
|
3497
3769
|
|
|
3498
3770
|
// src/server/runner/tick.ts
|
|
3499
3771
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3772
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3500
3773
|
async function runTick(ctx) {
|
|
3501
3774
|
try {
|
|
3502
3775
|
await ctx.collections.health.updateOne(
|
|
@@ -3537,12 +3810,18 @@ async function runTick(ctx) {
|
|
|
3537
3810
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3538
3811
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3539
3812
|
});
|
|
3813
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
3814
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
3815
|
+
});
|
|
3540
3816
|
await evaluateHealth(ctx).catch((err) => {
|
|
3541
3817
|
console.error("mailery: health evaluation failed", err);
|
|
3542
3818
|
});
|
|
3543
3819
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
3544
3820
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
3545
3821
|
});
|
|
3822
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
3823
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
3824
|
+
});
|
|
3546
3825
|
await Promise.all([
|
|
3547
3826
|
runDnsblChecks(ctx).catch((err) => {
|
|
3548
3827
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -3613,140 +3892,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
3613
3892
|
await processScheduledBroadcasts(ctx);
|
|
3614
3893
|
}
|
|
3615
3894
|
|
|
3616
|
-
// src/server/runner/webhook.ts
|
|
3617
|
-
function dimsFromSend(send) {
|
|
3618
|
-
if (!send) return null;
|
|
3619
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
3620
|
-
}
|
|
3621
|
-
async function applyWebhookEvent(event, ctx) {
|
|
3622
|
-
const send = await ctx.collections.sends.findOne(
|
|
3623
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
3624
|
-
{ sort: { queuedAt: -1 } }
|
|
3625
|
-
);
|
|
3626
|
-
switch (event.type) {
|
|
3627
|
-
case "delivered":
|
|
3628
|
-
if (send) {
|
|
3629
|
-
await ctx.collections.sends.updateOne(
|
|
3630
|
-
{ _id: send._id },
|
|
3631
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
3632
|
-
);
|
|
3633
|
-
}
|
|
3634
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
3635
|
-
break;
|
|
3636
|
-
case "open":
|
|
3637
|
-
if (send) {
|
|
3638
|
-
await ctx.collections.sends.updateOne(
|
|
3639
|
-
{ _id: send._id },
|
|
3640
|
-
{
|
|
3641
|
-
$set: {
|
|
3642
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
3643
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
3644
|
-
},
|
|
3645
|
-
$inc: { openCount: 1 }
|
|
3646
|
-
}
|
|
3647
|
-
);
|
|
3648
|
-
}
|
|
3649
|
-
break;
|
|
3650
|
-
case "click":
|
|
3651
|
-
if (send) {
|
|
3652
|
-
await ctx.collections.sends.updateOne(
|
|
3653
|
-
{ _id: send._id },
|
|
3654
|
-
{
|
|
3655
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
3656
|
-
$inc: { clickCount: 1 },
|
|
3657
|
-
$push: {
|
|
3658
|
-
clickedLinks: {
|
|
3659
|
-
url: event.details.clickedUrl ?? "",
|
|
3660
|
-
linkId: "",
|
|
3661
|
-
clickedAt: event.occurredAt
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
}
|
|
3665
|
-
);
|
|
3666
|
-
}
|
|
3667
|
-
break;
|
|
3668
|
-
case "bounce": {
|
|
3669
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
3670
|
-
if (send) {
|
|
3671
|
-
await ctx.collections.sends.updateOne(
|
|
3672
|
-
{ _id: send._id },
|
|
3673
|
-
{
|
|
3674
|
-
$set: {
|
|
3675
|
-
status: "bounced",
|
|
3676
|
-
bounceType,
|
|
3677
|
-
bounceReason: event.details.bounceReason ?? null
|
|
3678
|
-
}
|
|
3679
|
-
}
|
|
3680
|
-
);
|
|
3681
|
-
}
|
|
3682
|
-
if (bounceType === "hard") {
|
|
3683
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
3684
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3685
|
-
{ emailAtSubscribe: event.email },
|
|
3686
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3687
|
-
);
|
|
3688
|
-
}
|
|
3689
|
-
{
|
|
3690
|
-
const dims = dimsFromSend(send);
|
|
3691
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
3692
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
3693
|
-
}
|
|
3694
|
-
break;
|
|
3695
|
-
}
|
|
3696
|
-
case "complaint":
|
|
3697
|
-
case "spam_report":
|
|
3698
|
-
if (send) {
|
|
3699
|
-
await ctx.collections.sends.updateOne(
|
|
3700
|
-
{ _id: send._id },
|
|
3701
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
3702
|
-
);
|
|
3703
|
-
}
|
|
3704
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
3705
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3706
|
-
{ emailAtSubscribe: event.email },
|
|
3707
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3708
|
-
);
|
|
3709
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
3710
|
-
break;
|
|
3711
|
-
case "unsubscribe":
|
|
3712
|
-
if (send) {
|
|
3713
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
3714
|
-
}
|
|
3715
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
3716
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3717
|
-
{ emailAtSubscribe: event.email },
|
|
3718
|
-
{
|
|
3719
|
-
$set: {
|
|
3720
|
-
status: "unsubscribed",
|
|
3721
|
-
unsubscribedAt: event.occurredAt,
|
|
3722
|
-
unsubscribeReason: "user_request",
|
|
3723
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
3724
|
-
}
|
|
3725
|
-
}
|
|
3726
|
-
);
|
|
3727
|
-
break;
|
|
3728
|
-
}
|
|
3729
|
-
}
|
|
3730
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
3731
|
-
const normalized = email.toLowerCase();
|
|
3732
|
-
await ctx.collections.suppressions.updateOne(
|
|
3733
|
-
{ email: normalized, scope },
|
|
3734
|
-
{
|
|
3735
|
-
$setOnInsert: {
|
|
3736
|
-
email: normalized,
|
|
3737
|
-
emailHash: sha256Hex(normalized),
|
|
3738
|
-
scope,
|
|
3739
|
-
reason,
|
|
3740
|
-
source: "provider_webhook",
|
|
3741
|
-
notes: null,
|
|
3742
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
3743
|
-
expiresAt: null
|
|
3744
|
-
}
|
|
3745
|
-
},
|
|
3746
|
-
{ upsert: true }
|
|
3747
|
-
);
|
|
3748
|
-
}
|
|
3749
|
-
|
|
3750
3895
|
// src/server/mailer.ts
|
|
3751
3896
|
var Mailer = class _Mailer {
|
|
3752
3897
|
db;
|
|
@@ -3786,6 +3931,7 @@ var Mailer = class _Mailer {
|
|
|
3786
3931
|
* MAILER_MONGODB_URI — Mongo connection string (required)
|
|
3787
3932
|
* MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
|
|
3788
3933
|
* MAILER_REDIS_URL — Redis connection URL (required)
|
|
3934
|
+
* MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
|
|
3789
3935
|
* MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
|
|
3790
3936
|
* MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
|
|
3791
3937
|
* MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
|
|
@@ -3834,7 +3980,11 @@ var Mailer = class _Mailer {
|
|
|
3834
3980
|
}
|
|
3835
3981
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
3836
3982
|
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
3837
|
-
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
3983
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
3984
|
+
driver: "bull",
|
|
3985
|
+
redis: { url: required("MAILER_REDIS_URL") },
|
|
3986
|
+
prefix: env.MAILER_QUEUE_PREFIX
|
|
3987
|
+
};
|
|
3838
3988
|
return _Mailer.init({
|
|
3839
3989
|
db,
|
|
3840
3990
|
adapter,
|
|
@@ -4211,41 +4361,48 @@ var Mailer = class _Mailer {
|
|
|
4211
4361
|
if (existing) return { sendId: String(existing._id) };
|
|
4212
4362
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
4213
4363
|
const sendId = new ObjectId();
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4364
|
+
try {
|
|
4365
|
+
await this.collections.sends.insertOne({
|
|
4366
|
+
_id: sendId,
|
|
4367
|
+
dedupeKey,
|
|
4368
|
+
externalId: parsed.externalId,
|
|
4369
|
+
emailAtSend: contact.email,
|
|
4370
|
+
templateId: template._id,
|
|
4371
|
+
templateSlug: template.slug,
|
|
4372
|
+
flowRunId: null,
|
|
4373
|
+
broadcastId: null,
|
|
4374
|
+
manualSendBy: "sendOneOff",
|
|
4375
|
+
kind: template.kind,
|
|
4376
|
+
provider: providerName,
|
|
4377
|
+
providerMessageId: null,
|
|
4378
|
+
fromName: template.fromName,
|
|
4379
|
+
fromEmail: template.fromEmail,
|
|
4380
|
+
subject: template.subject,
|
|
4381
|
+
bodyHash: "",
|
|
4382
|
+
status: "queued",
|
|
4383
|
+
errorMessage: null,
|
|
4384
|
+
bounceType: null,
|
|
4385
|
+
bounceReason: null,
|
|
4386
|
+
links: [],
|
|
4387
|
+
vars: parsed.vars ?? {},
|
|
4388
|
+
openedAt: null,
|
|
4389
|
+
openCount: 0,
|
|
4390
|
+
firstClickAt: null,
|
|
4391
|
+
clickCount: 0,
|
|
4392
|
+
clickedLinks: [],
|
|
4393
|
+
unsubscribedAt: null,
|
|
4394
|
+
complainedAt: null,
|
|
4395
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
4396
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
4397
|
+
sentAt: null,
|
|
4398
|
+
deliveredAt: null
|
|
4399
|
+
});
|
|
4400
|
+
} catch (err) {
|
|
4401
|
+
if (err?.code !== 11e3) throw err;
|
|
4402
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
4403
|
+
if (winner) return { sendId: String(winner._id) };
|
|
4404
|
+
throw err;
|
|
4405
|
+
}
|
|
4249
4406
|
await this.queues.send.add(
|
|
4250
4407
|
"send",
|
|
4251
4408
|
{ sendId: String(sendId) },
|
|
@@ -4289,7 +4446,12 @@ var Mailer = class _Mailer {
|
|
|
4289
4446
|
await runTick(this.runnerContext);
|
|
4290
4447
|
},
|
|
4291
4448
|
advance: async (data) => {
|
|
4292
|
-
if (
|
|
4449
|
+
if (data.broadcastId) {
|
|
4450
|
+
if (!ObjectId.isValid(data.broadcastId)) return;
|
|
4451
|
+
await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
|
|
4452
|
+
return;
|
|
4453
|
+
}
|
|
4454
|
+
if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
|
|
4293
4455
|
await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
|
|
4294
4456
|
},
|
|
4295
4457
|
send: async (data) => {
|
|
@@ -4305,27 +4467,7 @@ var Mailer = class _Mailer {
|
|
|
4305
4467
|
}
|
|
4306
4468
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
4307
4469
|
async processWebhookBacklog() {
|
|
4308
|
-
|
|
4309
|
-
for (const evt of batch) {
|
|
4310
|
-
try {
|
|
4311
|
-
const normalized = evt.raw?.normalized;
|
|
4312
|
-
const details = normalized?.details ?? {};
|
|
4313
|
-
await applyWebhookEvent(
|
|
4314
|
-
{
|
|
4315
|
-
type: evt.normalizedType,
|
|
4316
|
-
providerEventId: evt.providerEventId,
|
|
4317
|
-
providerMessageId: evt.providerMessageId,
|
|
4318
|
-
email: evt.email,
|
|
4319
|
-
occurredAt: evt.occurredAt,
|
|
4320
|
-
details
|
|
4321
|
-
},
|
|
4322
|
-
this.runnerContext
|
|
4323
|
-
);
|
|
4324
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
4325
|
-
} catch (err) {
|
|
4326
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
4327
|
-
}
|
|
4328
|
-
}
|
|
4470
|
+
await processWebhookBacklog(this.runnerContext);
|
|
4329
4471
|
}
|
|
4330
4472
|
async stop() {
|
|
4331
4473
|
await this.queueDriver.close();
|
|
@@ -7391,7 +7533,7 @@ var DEDUPE_POLICIES = [
|
|
|
7391
7533
|
];
|
|
7392
7534
|
|
|
7393
7535
|
// src/server/index.ts
|
|
7394
|
-
var VERSION = "0.
|
|
7536
|
+
var VERSION = "0.9.0" ;
|
|
7395
7537
|
|
|
7396
7538
|
export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, varsJsonSchema, verifyUnsubscribeToken };
|
|
7397
7539
|
//# sourceMappingURL=index.js.map
|