mailery 0.7.0 → 0.8.1
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/admin/spa/{index-CjQTOX9H.js → index-B3ndbezm.js} +3 -3
- package/dist/admin/spa/index-B3ndbezm.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-yH0Oz4Ix.js → template-editor-DwMKy5sw.js} +3 -3
- package/dist/admin/spa/{template-editor-yH0Oz4Ix.js.map → template-editor-DwMKy5sw.js.map} +1 -1
- package/dist/index.cjs +807 -664
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +807 -664
- package/dist/index.js.map +1 -1
- package/dist/{null-B0rPgE5_.d.cts → null-CwXmiVLe.d.cts} +15 -1
- package/dist/{null-B0rPgE5_.d.ts → null-CwXmiVLe.d.ts} +15 -1
- package/dist/testing.cjs +875 -482
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +233 -13
- package/dist/testing.d.ts +233 -13
- package/dist/testing.js +869 -483
- package/dist/testing.js.map +1 -1
- package/package.json +5 -2
- package/dist/admin/spa/index-CjQTOX9H.js.map +0 -1
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 },
|
|
@@ -888,7 +894,13 @@ var BullDriver = class _BullDriver {
|
|
|
888
894
|
constructor(bull, redis) {
|
|
889
895
|
this.bull = bull;
|
|
890
896
|
this.redis = redis;
|
|
891
|
-
const opts = {
|
|
897
|
+
const opts = {
|
|
898
|
+
connection: redis,
|
|
899
|
+
defaultJobOptions: {
|
|
900
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
901
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
902
|
+
}
|
|
903
|
+
};
|
|
892
904
|
this.bullQueues = {
|
|
893
905
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
894
906
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -906,7 +918,11 @@ var BullDriver = class _BullDriver {
|
|
|
906
918
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
907
919
|
"mailer-tick-repeat",
|
|
908
920
|
{ every: intervalSeconds * 1e3 },
|
|
909
|
-
{
|
|
921
|
+
{
|
|
922
|
+
name: "tick",
|
|
923
|
+
data: {},
|
|
924
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
925
|
+
}
|
|
910
926
|
);
|
|
911
927
|
}
|
|
912
928
|
async startWorkers(opts) {
|
|
@@ -1016,9 +1032,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1016
1032
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
1017
1033
|
);
|
|
1018
1034
|
}
|
|
1035
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
1019
1036
|
const backend = new backendMod.MongoBackend({
|
|
1020
1037
|
mongo: opts.db,
|
|
1021
|
-
collection:
|
|
1038
|
+
collection: collectionName
|
|
1022
1039
|
});
|
|
1023
1040
|
const agenda = new agendaMod.Agenda({
|
|
1024
1041
|
backend,
|
|
@@ -1027,13 +1044,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1027
1044
|
maxConcurrency: 50,
|
|
1028
1045
|
defaultConcurrency: 5
|
|
1029
1046
|
});
|
|
1030
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
1047
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
1031
1048
|
}
|
|
1032
1049
|
db;
|
|
1033
|
-
|
|
1050
|
+
collName;
|
|
1051
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
1034
1052
|
this.agenda = agenda;
|
|
1035
1053
|
this.agendaMod = agendaMod;
|
|
1036
1054
|
this.db = db;
|
|
1055
|
+
this.collName = collectionName;
|
|
1037
1056
|
this.queues = {
|
|
1038
1057
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
1039
1058
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -1066,10 +1085,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1066
1085
|
}
|
|
1067
1086
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
1068
1087
|
jobsCollection() {
|
|
1069
|
-
return this.db.collection(this.
|
|
1070
|
-
}
|
|
1071
|
-
collectionName() {
|
|
1072
|
-
return "_mailerJobs";
|
|
1088
|
+
return this.db.collection(this.collName);
|
|
1073
1089
|
}
|
|
1074
1090
|
async findPending(name, jobId) {
|
|
1075
1091
|
return this.jobsCollection().findOne({
|
|
@@ -1079,12 +1095,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1079
1095
|
});
|
|
1080
1096
|
}
|
|
1081
1097
|
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
1098
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
1089
1099
|
}
|
|
1090
1100
|
async startWorkers(opts) {
|
|
@@ -1187,6 +1197,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
1187
1197
|
|
|
1188
1198
|
// src/server/runner/triggers.ts
|
|
1189
1199
|
var BATCH_SIZE = 1e3;
|
|
1200
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
1190
1201
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
1191
1202
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
1192
1203
|
for (const flow of flows) {
|
|
@@ -1197,16 +1208,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
1197
1208
|
const eventName = flow.trigger.eventName;
|
|
1198
1209
|
if (!eventName) return;
|
|
1199
1210
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
1200
|
-
const
|
|
1211
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
1212
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
1201
1213
|
if (events.length === 0) return;
|
|
1202
1214
|
for (const event of events) {
|
|
1203
1215
|
await tryEnterFlow(flow, event, ctx);
|
|
1204
1216
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1217
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
1218
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
1219
|
+
await ctx.collections.flows.updateOne(
|
|
1220
|
+
{ _id: flow._id },
|
|
1221
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1210
1224
|
}
|
|
1211
1225
|
async function tryEnterFlow(flow, event, ctx) {
|
|
1212
1226
|
if (flow.trigger.once) {
|
|
@@ -1218,188 +1232,562 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1218
1232
|
}
|
|
1219
1233
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
1220
1234
|
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
|
-
|
|
1235
|
+
let result;
|
|
1236
|
+
try {
|
|
1237
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
1238
|
+
externalId: event.externalId,
|
|
1239
|
+
flowId: flow._id,
|
|
1240
|
+
flowSlug: flow.slug,
|
|
1241
|
+
flowVersion: flow.version,
|
|
1242
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
1243
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1244
|
+
triggerDedupeKey: event.dedupeKey,
|
|
1245
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
1246
|
+
status: "active",
|
|
1247
|
+
currentStepIndex: 0,
|
|
1248
|
+
currentBranchPath: [],
|
|
1249
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
1250
|
+
attemptsForCurrentStep: 0,
|
|
1251
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
1252
|
+
exitedAt: null,
|
|
1253
|
+
exitReason: null,
|
|
1254
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1255
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1256
|
+
});
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
if (err?.code !== 11e3) throw err;
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1240
1261
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
1241
1262
|
}
|
|
1242
1263
|
|
|
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);
|
|
1264
|
+
// src/server/templates/sender-domain.ts
|
|
1265
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1266
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1267
|
+
const domain = extractDomain(fromEmail);
|
|
1268
|
+
if (!domain) {
|
|
1269
|
+
return {
|
|
1270
|
+
ok: false,
|
|
1271
|
+
code: "invalid_email",
|
|
1272
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1273
|
+
};
|
|
1285
1274
|
}
|
|
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);
|
|
1275
|
+
const entry = registry[domain];
|
|
1276
|
+
if (!entry) {
|
|
1277
|
+
const known = Object.keys(registry).join(", ");
|
|
1278
|
+
return {
|
|
1279
|
+
ok: false,
|
|
1280
|
+
code: "unregistered_domain",
|
|
1281
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1282
|
+
};
|
|
1304
1283
|
}
|
|
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++;
|
|
1284
|
+
if (entry.kind === "both") return { ok: true };
|
|
1285
|
+
if (entry.kind !== templateKind) {
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
code: "wrong_kind",
|
|
1289
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1290
|
+
};
|
|
1314
1291
|
}
|
|
1315
|
-
return
|
|
1292
|
+
return { ok: true };
|
|
1316
1293
|
}
|
|
1317
|
-
function
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
return null;
|
|
1294
|
+
function extractDomain(email) {
|
|
1295
|
+
if (typeof email !== "string") return null;
|
|
1296
|
+
const at = email.lastIndexOf("@");
|
|
1297
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
1298
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
1324
1299
|
}
|
|
1325
1300
|
|
|
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
|
-
|
|
1301
|
+
// src/server/runner/health.ts
|
|
1302
|
+
var ZERO_COUNTERS = {
|
|
1303
|
+
sent: 0,
|
|
1304
|
+
delivered: 0,
|
|
1305
|
+
bounced: 0,
|
|
1306
|
+
hardBounced: 0,
|
|
1307
|
+
softBounced: 0,
|
|
1308
|
+
complained: 0,
|
|
1309
|
+
failedToSend: 0
|
|
1310
|
+
};
|
|
1311
|
+
var ZERO_RATES = {
|
|
1312
|
+
bounceRate: 0,
|
|
1313
|
+
hardBounceRate: 0,
|
|
1314
|
+
complaintRate: 0,
|
|
1315
|
+
failureRate: 0
|
|
1316
|
+
};
|
|
1317
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1318
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1319
|
+
const writes = [];
|
|
1320
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1321
|
+
if (dims) {
|
|
1322
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1323
|
+
if (domain) {
|
|
1324
|
+
const id = healthBucketId(domain, dims.kind);
|
|
1325
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1349
1326
|
}
|
|
1350
1327
|
}
|
|
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";
|
|
1328
|
+
await Promise.all(writes);
|
|
1363
1329
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1330
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1331
|
+
await ctx.collections.health.updateOne(
|
|
1332
|
+
{ _id },
|
|
1333
|
+
{
|
|
1334
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
1335
|
+
$setOnInsert: {
|
|
1336
|
+
_id,
|
|
1337
|
+
senderDomain,
|
|
1338
|
+
kind,
|
|
1339
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1340
|
+
windowDurationMs: windowMs,
|
|
1341
|
+
status: "healthy",
|
|
1342
|
+
trippedAt: null,
|
|
1343
|
+
trippedReason: null,
|
|
1344
|
+
manuallyResumedAt: null,
|
|
1345
|
+
rates: { ...ZERO_RATES }
|
|
1346
|
+
},
|
|
1347
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1348
|
+
},
|
|
1349
|
+
{ upsert: true }
|
|
1350
|
+
);
|
|
1376
1351
|
}
|
|
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);
|
|
1352
|
+
async function evaluateHealth(ctx) {
|
|
1353
|
+
const cb = ctx.config.circuitBreaker;
|
|
1354
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1355
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
1356
|
+
if (docs.length === 0) return;
|
|
1357
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1358
|
+
if (!hasAgg) {
|
|
1359
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1393
1360
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1361
|
+
for (const doc of docs) {
|
|
1362
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1363
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1364
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1365
|
+
await ctx.collections.health.updateOne(
|
|
1366
|
+
{ _id: doc._id },
|
|
1367
|
+
{
|
|
1368
|
+
$set: {
|
|
1369
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1370
|
+
windowDurationMs: windowMs,
|
|
1371
|
+
counters: { ...ZERO_COUNTERS },
|
|
1372
|
+
rates: { ...ZERO_RATES },
|
|
1373
|
+
status: "healthy",
|
|
1374
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
);
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const c = doc.counters;
|
|
1381
|
+
const total = c.sent || 1;
|
|
1382
|
+
const rates = {
|
|
1383
|
+
bounceRate: c.bounced / total,
|
|
1384
|
+
hardBounceRate: c.hardBounced / total,
|
|
1385
|
+
complaintRate: c.complained / total,
|
|
1386
|
+
failureRate: c.failedToSend / total
|
|
1387
|
+
};
|
|
1388
|
+
await ctx.collections.health.updateOne(
|
|
1389
|
+
{ _id: doc._id },
|
|
1390
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1391
|
+
);
|
|
1392
|
+
if (isAgg) continue;
|
|
1393
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1394
|
+
if (doc.status === "tripped") continue;
|
|
1395
|
+
let trippedReason = null;
|
|
1396
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1397
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1398
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1399
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1400
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1401
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1402
|
+
}
|
|
1403
|
+
if (trippedReason) {
|
|
1404
|
+
const result = await ctx.collections.health.updateOne(
|
|
1405
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1406
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1407
|
+
);
|
|
1408
|
+
if (result.modifiedCount > 0) {
|
|
1409
|
+
if (ctx.audit) {
|
|
1410
|
+
try {
|
|
1411
|
+
await ctx.audit({
|
|
1412
|
+
actor: "system:circuit-breaker",
|
|
1413
|
+
action: "health.trip",
|
|
1414
|
+
resource: {
|
|
1415
|
+
collection: "mailer_health",
|
|
1416
|
+
id: String(doc._id),
|
|
1417
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1418
|
+
},
|
|
1419
|
+
diffSummary: trippedReason
|
|
1420
|
+
});
|
|
1421
|
+
} catch {
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
1425
|
+
try {
|
|
1426
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
1427
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1428
|
+
rates
|
|
1429
|
+
});
|
|
1430
|
+
} catch {
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1437
|
+
if (doc.status !== "degraded") {
|
|
1438
|
+
await ctx.collections.health.updateOne(
|
|
1439
|
+
{ _id: doc._id },
|
|
1440
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
} else if (doc.status === "degraded") {
|
|
1444
|
+
await ctx.collections.health.updateOne(
|
|
1445
|
+
{ _id: doc._id },
|
|
1446
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
1452
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
1453
|
+
const id = healthBucketId(domain, kind);
|
|
1454
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
1455
|
+
}
|
|
1456
|
+
function effectiveOverallStatus(docs) {
|
|
1457
|
+
const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
|
|
1458
|
+
if (buckets.length === 0) {
|
|
1459
|
+
return docs.length === 0 ? null : "healthy";
|
|
1460
|
+
}
|
|
1461
|
+
if (buckets.some((d) => d.status === "tripped")) return "tripped";
|
|
1462
|
+
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1463
|
+
return "healthy";
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/server/runner/webhook.ts
|
|
1467
|
+
function dimsFromSend(send) {
|
|
1468
|
+
if (!send) return null;
|
|
1469
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
1470
|
+
}
|
|
1471
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
1472
|
+
const filter = { processed: false };
|
|
1473
|
+
if (opts.olderThanMs) {
|
|
1474
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
1475
|
+
}
|
|
1476
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
1477
|
+
for (const evt of batch) {
|
|
1478
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
1479
|
+
{ _id: evt._id, processed: false },
|
|
1480
|
+
{ $set: { processed: true } }
|
|
1481
|
+
);
|
|
1482
|
+
if (!claimed) continue;
|
|
1483
|
+
try {
|
|
1484
|
+
const normalized = evt.raw?.normalized;
|
|
1485
|
+
const details = normalized?.details ?? {};
|
|
1486
|
+
await applyWebhookEvent(
|
|
1487
|
+
{
|
|
1488
|
+
type: evt.normalizedType,
|
|
1489
|
+
providerEventId: evt.providerEventId,
|
|
1490
|
+
providerMessageId: evt.providerMessageId,
|
|
1491
|
+
email: evt.email,
|
|
1492
|
+
occurredAt: evt.occurredAt,
|
|
1493
|
+
details
|
|
1494
|
+
},
|
|
1495
|
+
ctx
|
|
1496
|
+
);
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
async function applyWebhookEvent(event, ctx) {
|
|
1503
|
+
const send = await ctx.collections.sends.findOne(
|
|
1504
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
1505
|
+
{ sort: { queuedAt: -1 } }
|
|
1506
|
+
);
|
|
1507
|
+
switch (event.type) {
|
|
1508
|
+
case "delivered":
|
|
1509
|
+
if (send) {
|
|
1510
|
+
await ctx.collections.sends.updateOne(
|
|
1511
|
+
{ _id: send._id },
|
|
1512
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
1516
|
+
break;
|
|
1517
|
+
case "open":
|
|
1518
|
+
if (send) {
|
|
1519
|
+
await ctx.collections.sends.updateOne(
|
|
1520
|
+
{ _id: send._id },
|
|
1521
|
+
{
|
|
1522
|
+
$set: {
|
|
1523
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
1524
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
1525
|
+
},
|
|
1526
|
+
$inc: { openCount: 1 }
|
|
1527
|
+
}
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
break;
|
|
1531
|
+
case "click":
|
|
1532
|
+
if (send) {
|
|
1533
|
+
await ctx.collections.sends.updateOne(
|
|
1534
|
+
{ _id: send._id },
|
|
1535
|
+
{
|
|
1536
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
1537
|
+
$inc: { clickCount: 1 },
|
|
1538
|
+
$push: {
|
|
1539
|
+
clickedLinks: {
|
|
1540
|
+
url: event.details.clickedUrl ?? "",
|
|
1541
|
+
linkId: "",
|
|
1542
|
+
clickedAt: event.occurredAt
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
break;
|
|
1549
|
+
case "bounce": {
|
|
1550
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
1551
|
+
if (send) {
|
|
1552
|
+
await ctx.collections.sends.updateOne(
|
|
1553
|
+
{ _id: send._id },
|
|
1554
|
+
{
|
|
1555
|
+
$set: {
|
|
1556
|
+
status: "bounced",
|
|
1557
|
+
bounceType,
|
|
1558
|
+
bounceReason: event.details.bounceReason ?? null
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
if (bounceType === "hard") {
|
|
1564
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
1565
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1566
|
+
{ emailAtSubscribe: event.email },
|
|
1567
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
{
|
|
1571
|
+
const dims = dimsFromSend(send);
|
|
1572
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
1573
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
1574
|
+
}
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
case "complaint":
|
|
1578
|
+
case "spam_report":
|
|
1579
|
+
if (send) {
|
|
1580
|
+
await ctx.collections.sends.updateOne(
|
|
1581
|
+
{ _id: send._id },
|
|
1582
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
1586
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1587
|
+
{ emailAtSubscribe: event.email },
|
|
1588
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1589
|
+
);
|
|
1590
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
1591
|
+
break;
|
|
1592
|
+
case "unsubscribe":
|
|
1593
|
+
if (send) {
|
|
1594
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
1595
|
+
}
|
|
1596
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
1597
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1598
|
+
{ emailAtSubscribe: event.email },
|
|
1599
|
+
{
|
|
1600
|
+
$set: {
|
|
1601
|
+
status: "unsubscribed",
|
|
1602
|
+
unsubscribedAt: event.occurredAt,
|
|
1603
|
+
unsubscribeReason: "user_request",
|
|
1604
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
);
|
|
1608
|
+
break;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
1612
|
+
const normalized = email.toLowerCase();
|
|
1613
|
+
await ctx.collections.suppressions.updateOne(
|
|
1614
|
+
{ email: normalized, scope },
|
|
1615
|
+
{
|
|
1616
|
+
$setOnInsert: {
|
|
1617
|
+
email: normalized,
|
|
1618
|
+
emailHash: sha256Hex(normalized),
|
|
1619
|
+
scope,
|
|
1620
|
+
reason,
|
|
1621
|
+
source: "provider_webhook",
|
|
1622
|
+
notes: null,
|
|
1623
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
1624
|
+
expiresAt: null
|
|
1625
|
+
}
|
|
1626
|
+
},
|
|
1627
|
+
{ upsert: true }
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// src/server/runner/predicate.ts
|
|
1632
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1633
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
1634
|
+
const p = predicate;
|
|
1635
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
1636
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
1637
|
+
if ("fieldEquals" in p) {
|
|
1638
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
1639
|
+
}
|
|
1640
|
+
if ("fieldExists" in p) {
|
|
1641
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1642
|
+
}
|
|
1643
|
+
if ("subscriptionStatus" in p) {
|
|
1644
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1645
|
+
return sub?.status === p.subscriptionStatus;
|
|
1646
|
+
}
|
|
1647
|
+
if ("hasFiredEvent" in p) {
|
|
1648
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1649
|
+
}
|
|
1650
|
+
if ("notHasFiredEvent" in p) {
|
|
1651
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1652
|
+
}
|
|
1653
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1654
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1655
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1656
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1657
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1658
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1659
|
+
if ("all" in p) {
|
|
1660
|
+
for (const sub of p.all) {
|
|
1661
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1662
|
+
}
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
if ("any" in p) {
|
|
1666
|
+
for (const sub of p.any) {
|
|
1667
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1668
|
+
}
|
|
1669
|
+
return false;
|
|
1670
|
+
}
|
|
1671
|
+
if ("not" in p) {
|
|
1672
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
1673
|
+
}
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
async function hasEvent(ctx, name, opts) {
|
|
1677
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
1678
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1679
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
1680
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
1681
|
+
return !!found;
|
|
1682
|
+
}
|
|
1683
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1684
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
1685
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1686
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1687
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1688
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1689
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1690
|
+
if (!excludeBots) {
|
|
1691
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
1692
|
+
}
|
|
1693
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1694
|
+
let n = 0;
|
|
1695
|
+
for (const s of docs) {
|
|
1696
|
+
if (kind === "opened") {
|
|
1697
|
+
n++;
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1701
|
+
if (hasHumanClick) n++;
|
|
1702
|
+
}
|
|
1703
|
+
return n;
|
|
1704
|
+
}
|
|
1705
|
+
function effectiveLowerBound(ctx, opts) {
|
|
1706
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1707
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
1708
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
1709
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
1710
|
+
}
|
|
1711
|
+
return null;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// src/server/runner/delivery-window.ts
|
|
1715
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1716
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1717
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1718
|
+
let candidate = now;
|
|
1719
|
+
if (window.timeOfDay) {
|
|
1720
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1721
|
+
const local = localParts(candidate, tz);
|
|
1722
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1723
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1724
|
+
candidate = todaySlot;
|
|
1725
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1726
|
+
const next = addLocalDays(local, 1);
|
|
1727
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
if (window.weekdaysOnly) {
|
|
1731
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1732
|
+
const local = localParts(candidate, tz);
|
|
1733
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1734
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1735
|
+
const moved = addLocalDays(local, shift);
|
|
1736
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return candidate;
|
|
1740
|
+
}
|
|
1741
|
+
function pickTimezone(window, contactTimezone) {
|
|
1742
|
+
const candidates = [
|
|
1743
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1744
|
+
window.timezone,
|
|
1745
|
+
"UTC"
|
|
1746
|
+
];
|
|
1747
|
+
for (const tz of candidates) {
|
|
1748
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1749
|
+
}
|
|
1750
|
+
return "UTC";
|
|
1751
|
+
}
|
|
1752
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1753
|
+
function isValidTimezone(tz) {
|
|
1754
|
+
const cached = validatedZones.get(tz);
|
|
1755
|
+
if (cached !== void 0) return cached;
|
|
1756
|
+
let ok = true;
|
|
1757
|
+
try {
|
|
1758
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1759
|
+
} catch {
|
|
1760
|
+
ok = false;
|
|
1761
|
+
}
|
|
1762
|
+
validatedZones.set(tz, ok);
|
|
1763
|
+
return ok;
|
|
1764
|
+
}
|
|
1765
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1766
|
+
function formatterFor(tz) {
|
|
1767
|
+
let f = partFormatters.get(tz);
|
|
1768
|
+
if (!f) {
|
|
1769
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1770
|
+
timeZone: tz,
|
|
1771
|
+
year: "numeric",
|
|
1772
|
+
month: "2-digit",
|
|
1773
|
+
day: "2-digit",
|
|
1774
|
+
hour: "2-digit",
|
|
1775
|
+
minute: "2-digit",
|
|
1776
|
+
second: "2-digit",
|
|
1777
|
+
weekday: "short",
|
|
1778
|
+
hour12: false
|
|
1779
|
+
});
|
|
1780
|
+
partFormatters.set(tz, f);
|
|
1781
|
+
}
|
|
1782
|
+
return f;
|
|
1783
|
+
}
|
|
1784
|
+
function localParts(date, tz) {
|
|
1785
|
+
const parts = {};
|
|
1786
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1787
|
+
return {
|
|
1788
|
+
y: Number(parts.year),
|
|
1789
|
+
mo: Number(parts.month),
|
|
1790
|
+
d: Number(parts.day),
|
|
1403
1791
|
hh: Number(parts.hour) % 24,
|
|
1404
1792
|
// Intl emits '24' for midnight in some locales
|
|
1405
1793
|
mi: Number(parts.minute),
|
|
@@ -1469,7 +1857,8 @@ function applyTracking(html, opts) {
|
|
|
1469
1857
|
const seen = /* @__PURE__ */ new Map();
|
|
1470
1858
|
let out = html;
|
|
1471
1859
|
if (opts.trackClicks) {
|
|
1472
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
1860
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
1861
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
1473
1862
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
1474
1863
|
let linkId = seen.get(url);
|
|
1475
1864
|
if (!linkId) {
|
|
@@ -1501,6 +1890,29 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
1501
1890
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
1502
1891
|
return false;
|
|
1503
1892
|
}
|
|
1893
|
+
var NAMED_ENTITIES = {
|
|
1894
|
+
amp: "&",
|
|
1895
|
+
lt: "<",
|
|
1896
|
+
gt: ">",
|
|
1897
|
+
quot: '"',
|
|
1898
|
+
apos: "'",
|
|
1899
|
+
nbsp: "\xA0"
|
|
1900
|
+
};
|
|
1901
|
+
function decodeHtmlEntities(input) {
|
|
1902
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
1903
|
+
if (body[0] === "#") {
|
|
1904
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
1905
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
1906
|
+
try {
|
|
1907
|
+
return String.fromCodePoint(code);
|
|
1908
|
+
} catch {
|
|
1909
|
+
return match;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
1913
|
+
return named ?? match;
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1504
1916
|
function shortHash(input) {
|
|
1505
1917
|
return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
1506
1918
|
}
|
|
@@ -1513,262 +1925,60 @@ function makeHandlebars(extra) {
|
|
|
1513
1925
|
hb.registerHelper("gte", (a, b) => a >= b);
|
|
1514
1926
|
hb.registerHelper("lte", (a, b) => a <= b);
|
|
1515
1927
|
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
|
-
);
|
|
1928
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1929
|
+
hb.registerHelper("not", (a) => !a);
|
|
1930
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1931
|
+
if (!value) return "";
|
|
1932
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
1933
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
1934
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1935
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1936
|
+
return d.toISOString().slice(0, 10);
|
|
1937
|
+
});
|
|
1938
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
1939
|
+
const v = Number(n);
|
|
1940
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1941
|
+
});
|
|
1942
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1943
|
+
const v = Number(cents);
|
|
1944
|
+
if (!Number.isFinite(v)) return "";
|
|
1945
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1946
|
+
});
|
|
1947
|
+
hb.registerHelper(
|
|
1948
|
+
"pluralize",
|
|
1949
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
1950
|
+
);
|
|
1951
|
+
if (extra) {
|
|
1952
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
1953
|
+
hb.registerHelper(name, fn);
|
|
1756
1954
|
}
|
|
1757
1955
|
}
|
|
1956
|
+
return hb;
|
|
1758
1957
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1958
|
+
|
|
1959
|
+
// src/server/runner/send.ts
|
|
1960
|
+
init_vars();
|
|
1961
|
+
|
|
1962
|
+
// src/server/runner/suppression.ts
|
|
1963
|
+
var SCOPES_BY_KIND = {
|
|
1964
|
+
marketing: ["all", "marketing"],
|
|
1965
|
+
transactional: ["all", "transactional"]
|
|
1966
|
+
};
|
|
1967
|
+
async function isSuppressed(collections, email, kind) {
|
|
1968
|
+
const normalized = email.toLowerCase();
|
|
1969
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
1970
|
+
const byEmail = await collections.suppressions.findOne({
|
|
1971
|
+
email: normalized,
|
|
1972
|
+
scope: { $in: allowed },
|
|
1973
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
1974
|
+
});
|
|
1975
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
1976
|
+
const hashed = await collections.suppressions.findOne({
|
|
1977
|
+
emailHash: sha256Hex(normalized),
|
|
1978
|
+
scope: { $in: allowed }
|
|
1979
|
+
});
|
|
1980
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
1981
|
+
return { suppressed: false };
|
|
1772
1982
|
}
|
|
1773
1983
|
|
|
1774
1984
|
// src/server/runner/send.ts
|
|
@@ -1836,9 +2046,11 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
1836
2046
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
1837
2047
|
}
|
|
1838
2048
|
async function dispatchSend(sendId, ctx) {
|
|
1839
|
-
const send = await ctx.collections.sends.
|
|
2049
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
2050
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
2051
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2052
|
+
);
|
|
1840
2053
|
if (!send) return;
|
|
1841
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
1842
2054
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
1843
2055
|
if (!template) {
|
|
1844
2056
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -1855,6 +2067,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1855
2067
|
if (send.kind === "marketing") {
|
|
1856
2068
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
1857
2069
|
if (bucket?.status === "tripped") {
|
|
2070
|
+
await ctx.collections.sends.updateOne(
|
|
2071
|
+
{ _id: send._id },
|
|
2072
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2073
|
+
);
|
|
1858
2074
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
1859
2075
|
return;
|
|
1860
2076
|
}
|
|
@@ -1914,6 +2130,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1914
2130
|
await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
|
|
1915
2131
|
return;
|
|
1916
2132
|
}
|
|
2133
|
+
const headers = send.kind === "marketing" ? {
|
|
2134
|
+
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
2135
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
2136
|
+
} : {};
|
|
1917
2137
|
try {
|
|
1918
2138
|
const result = await provider.send({
|
|
1919
2139
|
to: send.emailAtSend,
|
|
@@ -1923,10 +2143,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1923
2143
|
subject: rendered.subject,
|
|
1924
2144
|
html: tracking.html,
|
|
1925
2145
|
text: rendered.plainText,
|
|
1926
|
-
headers
|
|
1927
|
-
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
1928
|
-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
1929
|
-
},
|
|
2146
|
+
headers,
|
|
1930
2147
|
messageMeta: { sendId: String(send._id) }
|
|
1931
2148
|
});
|
|
1932
2149
|
await ctx.collections.sends.updateOne(
|
|
@@ -2102,7 +2319,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2102
2319
|
await ctx.queues.advance.add(
|
|
2103
2320
|
"advance",
|
|
2104
2321
|
{ flowRunId: String(run._id) },
|
|
2105
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
2322
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
2106
2323
|
);
|
|
2107
2324
|
}
|
|
2108
2325
|
async function handleCondition(run, step, contact, ctx) {
|
|
@@ -2214,7 +2431,7 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
2214
2431
|
await ctx.queues.advance.add(
|
|
2215
2432
|
"advance",
|
|
2216
2433
|
{ flowRunId: String(run._id) },
|
|
2217
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2434
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2218
2435
|
);
|
|
2219
2436
|
}
|
|
2220
2437
|
}
|
|
@@ -2243,7 +2460,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
2243
2460
|
{ flowRunId: String(run._id) },
|
|
2244
2461
|
{
|
|
2245
2462
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2246
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2463
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2247
2464
|
}
|
|
2248
2465
|
);
|
|
2249
2466
|
}
|
|
@@ -2298,13 +2515,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
2298
2515
|
let arr = steps;
|
|
2299
2516
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
2300
2517
|
const parentIndex = branchPath[i];
|
|
2301
|
-
const
|
|
2518
|
+
const branchKey2 = branchPath[i + 1];
|
|
2302
2519
|
const parent = arr[parentIndex];
|
|
2303
2520
|
if (!parent || parent.type !== "branch") return null;
|
|
2304
|
-
arr =
|
|
2521
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
2305
2522
|
}
|
|
2306
2523
|
return arr[currentStepIndex] ?? null;
|
|
2307
2524
|
}
|
|
2525
|
+
function branchKey(run) {
|
|
2526
|
+
return run.currentBranchPath.join("_");
|
|
2527
|
+
}
|
|
2308
2528
|
function unitToMs(value, unit) {
|
|
2309
2529
|
const m = 6e4;
|
|
2310
2530
|
switch (unit) {
|
|
@@ -2331,6 +2551,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
2331
2551
|
}
|
|
2332
2552
|
}
|
|
2333
2553
|
}
|
|
2554
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
2334
2555
|
async function processScheduledBroadcasts(ctx) {
|
|
2335
2556
|
const now = /* @__PURE__ */ new Date();
|
|
2336
2557
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -2341,15 +2562,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2341
2562
|
{ returnDocument: "after" }
|
|
2342
2563
|
);
|
|
2343
2564
|
if (!claimed) continue;
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2565
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
2569
|
+
if (ctx.config.queue.driver === "noop") {
|
|
2570
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
await ctx.queues.advance.add(
|
|
2574
|
+
"advance",
|
|
2575
|
+
{ broadcastId: String(broadcast._id) },
|
|
2576
|
+
{
|
|
2577
|
+
attempts: 3,
|
|
2578
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
2579
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
2352
2580
|
}
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
2584
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
2585
|
+
if (!broadcast) return;
|
|
2586
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2587
|
+
}
|
|
2588
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
2589
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
2590
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
2591
|
+
for (const b of stalled) {
|
|
2592
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2593
|
+
{ _id: b._id },
|
|
2594
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2595
|
+
);
|
|
2596
|
+
await startBroadcastDispatch(b, ctx);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
2600
|
+
try {
|
|
2601
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
2602
|
+
} catch (err) {
|
|
2603
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
2604
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2605
|
+
{ _id: broadcast._id },
|
|
2606
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2607
|
+
);
|
|
2353
2608
|
}
|
|
2354
2609
|
}
|
|
2355
2610
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -2370,11 +2625,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
2370
2625
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
2371
2626
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
2372
2627
|
for (; ; ) {
|
|
2628
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2629
|
+
{ _id: broadcast._id },
|
|
2630
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2631
|
+
);
|
|
2373
2632
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
2374
2633
|
if (page.contacts.length === 0) break;
|
|
2375
2634
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
2376
2635
|
if (eligible.length > 0) {
|
|
2377
2636
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
2637
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2638
|
+
{ _id: broadcast._id },
|
|
2639
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2640
|
+
);
|
|
2378
2641
|
await sleep(2e3);
|
|
2379
2642
|
}
|
|
2380
2643
|
const sendDocs = await Promise.all(
|
|
@@ -2497,7 +2760,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2497
2760
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
2498
2761
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
2499
2762
|
if (respectTimezone && contact.timezone) {
|
|
2500
|
-
|
|
2763
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
2764
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
2501
2765
|
}
|
|
2502
2766
|
const doc = {
|
|
2503
2767
|
_id: sendId,
|
|
@@ -2536,7 +2800,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2536
2800
|
};
|
|
2537
2801
|
return { doc, delayMs };
|
|
2538
2802
|
}
|
|
2539
|
-
function
|
|
2803
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
2804
|
+
const DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2540
2805
|
try {
|
|
2541
2806
|
const scheduled = new Date(scheduledMs);
|
|
2542
2807
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -2549,7 +2814,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
2549
2814
|
const utcMs = parse(utc);
|
|
2550
2815
|
const localMs = parse(local);
|
|
2551
2816
|
const offsetMs = utcMs - localMs;
|
|
2552
|
-
return offsetMs;
|
|
2817
|
+
return (offsetMs % DAY_MS2 + DAY_MS2) % DAY_MS2;
|
|
2553
2818
|
} catch {
|
|
2554
2819
|
return 0;
|
|
2555
2820
|
}
|
|
@@ -3496,6 +3761,7 @@ function suggestPolicyProgression(input) {
|
|
|
3496
3761
|
|
|
3497
3762
|
// src/server/runner/tick.ts
|
|
3498
3763
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3764
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3499
3765
|
async function runTick(ctx) {
|
|
3500
3766
|
try {
|
|
3501
3767
|
await ctx.collections.health.updateOne(
|
|
@@ -3536,12 +3802,18 @@ async function runTick(ctx) {
|
|
|
3536
3802
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3537
3803
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3538
3804
|
});
|
|
3805
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
3806
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
3807
|
+
});
|
|
3539
3808
|
await evaluateHealth(ctx).catch((err) => {
|
|
3540
3809
|
console.error("mailery: health evaluation failed", err);
|
|
3541
3810
|
});
|
|
3542
3811
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
3543
3812
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
3544
3813
|
});
|
|
3814
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
3815
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
3816
|
+
});
|
|
3545
3817
|
await Promise.all([
|
|
3546
3818
|
runDnsblChecks(ctx).catch((err) => {
|
|
3547
3819
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -3612,140 +3884,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
3612
3884
|
await processScheduledBroadcasts(ctx);
|
|
3613
3885
|
}
|
|
3614
3886
|
|
|
3615
|
-
// src/server/runner/webhook.ts
|
|
3616
|
-
function dimsFromSend(send) {
|
|
3617
|
-
if (!send) return null;
|
|
3618
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
3619
|
-
}
|
|
3620
|
-
async function applyWebhookEvent(event, ctx) {
|
|
3621
|
-
const send = await ctx.collections.sends.findOne(
|
|
3622
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
3623
|
-
{ sort: { queuedAt: -1 } }
|
|
3624
|
-
);
|
|
3625
|
-
switch (event.type) {
|
|
3626
|
-
case "delivered":
|
|
3627
|
-
if (send) {
|
|
3628
|
-
await ctx.collections.sends.updateOne(
|
|
3629
|
-
{ _id: send._id },
|
|
3630
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
3631
|
-
);
|
|
3632
|
-
}
|
|
3633
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
3634
|
-
break;
|
|
3635
|
-
case "open":
|
|
3636
|
-
if (send) {
|
|
3637
|
-
await ctx.collections.sends.updateOne(
|
|
3638
|
-
{ _id: send._id },
|
|
3639
|
-
{
|
|
3640
|
-
$set: {
|
|
3641
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
3642
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
3643
|
-
},
|
|
3644
|
-
$inc: { openCount: 1 }
|
|
3645
|
-
}
|
|
3646
|
-
);
|
|
3647
|
-
}
|
|
3648
|
-
break;
|
|
3649
|
-
case "click":
|
|
3650
|
-
if (send) {
|
|
3651
|
-
await ctx.collections.sends.updateOne(
|
|
3652
|
-
{ _id: send._id },
|
|
3653
|
-
{
|
|
3654
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
3655
|
-
$inc: { clickCount: 1 },
|
|
3656
|
-
$push: {
|
|
3657
|
-
clickedLinks: {
|
|
3658
|
-
url: event.details.clickedUrl ?? "",
|
|
3659
|
-
linkId: "",
|
|
3660
|
-
clickedAt: event.occurredAt
|
|
3661
|
-
}
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
);
|
|
3665
|
-
}
|
|
3666
|
-
break;
|
|
3667
|
-
case "bounce": {
|
|
3668
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
3669
|
-
if (send) {
|
|
3670
|
-
await ctx.collections.sends.updateOne(
|
|
3671
|
-
{ _id: send._id },
|
|
3672
|
-
{
|
|
3673
|
-
$set: {
|
|
3674
|
-
status: "bounced",
|
|
3675
|
-
bounceType,
|
|
3676
|
-
bounceReason: event.details.bounceReason ?? null
|
|
3677
|
-
}
|
|
3678
|
-
}
|
|
3679
|
-
);
|
|
3680
|
-
}
|
|
3681
|
-
if (bounceType === "hard") {
|
|
3682
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
3683
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3684
|
-
{ emailAtSubscribe: event.email },
|
|
3685
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3686
|
-
);
|
|
3687
|
-
}
|
|
3688
|
-
{
|
|
3689
|
-
const dims = dimsFromSend(send);
|
|
3690
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
3691
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
3692
|
-
}
|
|
3693
|
-
break;
|
|
3694
|
-
}
|
|
3695
|
-
case "complaint":
|
|
3696
|
-
case "spam_report":
|
|
3697
|
-
if (send) {
|
|
3698
|
-
await ctx.collections.sends.updateOne(
|
|
3699
|
-
{ _id: send._id },
|
|
3700
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
3701
|
-
);
|
|
3702
|
-
}
|
|
3703
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
3704
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3705
|
-
{ emailAtSubscribe: event.email },
|
|
3706
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3707
|
-
);
|
|
3708
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
3709
|
-
break;
|
|
3710
|
-
case "unsubscribe":
|
|
3711
|
-
if (send) {
|
|
3712
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
3713
|
-
}
|
|
3714
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
3715
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3716
|
-
{ emailAtSubscribe: event.email },
|
|
3717
|
-
{
|
|
3718
|
-
$set: {
|
|
3719
|
-
status: "unsubscribed",
|
|
3720
|
-
unsubscribedAt: event.occurredAt,
|
|
3721
|
-
unsubscribeReason: "user_request",
|
|
3722
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
3723
|
-
}
|
|
3724
|
-
}
|
|
3725
|
-
);
|
|
3726
|
-
break;
|
|
3727
|
-
}
|
|
3728
|
-
}
|
|
3729
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
3730
|
-
const normalized = email.toLowerCase();
|
|
3731
|
-
await ctx.collections.suppressions.updateOne(
|
|
3732
|
-
{ email: normalized, scope },
|
|
3733
|
-
{
|
|
3734
|
-
$setOnInsert: {
|
|
3735
|
-
email: normalized,
|
|
3736
|
-
emailHash: sha256Hex(normalized),
|
|
3737
|
-
scope,
|
|
3738
|
-
reason,
|
|
3739
|
-
source: "provider_webhook",
|
|
3740
|
-
notes: null,
|
|
3741
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
3742
|
-
expiresAt: null
|
|
3743
|
-
}
|
|
3744
|
-
},
|
|
3745
|
-
{ upsert: true }
|
|
3746
|
-
);
|
|
3747
|
-
}
|
|
3748
|
-
|
|
3749
3887
|
// src/server/mailer.ts
|
|
3750
3888
|
var Mailer = class _Mailer {
|
|
3751
3889
|
db;
|
|
@@ -4210,41 +4348,48 @@ var Mailer = class _Mailer {
|
|
|
4210
4348
|
if (existing) return { sendId: String(existing._id) };
|
|
4211
4349
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
4212
4350
|
const sendId = new ObjectId();
|
|
4213
|
-
|
|
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
|
-
|
|
4351
|
+
try {
|
|
4352
|
+
await this.collections.sends.insertOne({
|
|
4353
|
+
_id: sendId,
|
|
4354
|
+
dedupeKey,
|
|
4355
|
+
externalId: parsed.externalId,
|
|
4356
|
+
emailAtSend: contact.email,
|
|
4357
|
+
templateId: template._id,
|
|
4358
|
+
templateSlug: template.slug,
|
|
4359
|
+
flowRunId: null,
|
|
4360
|
+
broadcastId: null,
|
|
4361
|
+
manualSendBy: "sendOneOff",
|
|
4362
|
+
kind: template.kind,
|
|
4363
|
+
provider: providerName,
|
|
4364
|
+
providerMessageId: null,
|
|
4365
|
+
fromName: template.fromName,
|
|
4366
|
+
fromEmail: template.fromEmail,
|
|
4367
|
+
subject: template.subject,
|
|
4368
|
+
bodyHash: "",
|
|
4369
|
+
status: "queued",
|
|
4370
|
+
errorMessage: null,
|
|
4371
|
+
bounceType: null,
|
|
4372
|
+
bounceReason: null,
|
|
4373
|
+
links: [],
|
|
4374
|
+
vars: parsed.vars ?? {},
|
|
4375
|
+
openedAt: null,
|
|
4376
|
+
openCount: 0,
|
|
4377
|
+
firstClickAt: null,
|
|
4378
|
+
clickCount: 0,
|
|
4379
|
+
clickedLinks: [],
|
|
4380
|
+
unsubscribedAt: null,
|
|
4381
|
+
complainedAt: null,
|
|
4382
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
4383
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
4384
|
+
sentAt: null,
|
|
4385
|
+
deliveredAt: null
|
|
4386
|
+
});
|
|
4387
|
+
} catch (err) {
|
|
4388
|
+
if (err?.code !== 11e3) throw err;
|
|
4389
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
4390
|
+
if (winner) return { sendId: String(winner._id) };
|
|
4391
|
+
throw err;
|
|
4392
|
+
}
|
|
4248
4393
|
await this.queues.send.add(
|
|
4249
4394
|
"send",
|
|
4250
4395
|
{ sendId: String(sendId) },
|
|
@@ -4288,7 +4433,12 @@ var Mailer = class _Mailer {
|
|
|
4288
4433
|
await runTick(this.runnerContext);
|
|
4289
4434
|
},
|
|
4290
4435
|
advance: async (data) => {
|
|
4291
|
-
if (
|
|
4436
|
+
if (data.broadcastId) {
|
|
4437
|
+
if (!ObjectId.isValid(data.broadcastId)) return;
|
|
4438
|
+
await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
|
|
4439
|
+
return;
|
|
4440
|
+
}
|
|
4441
|
+
if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
|
|
4292
4442
|
await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
|
|
4293
4443
|
},
|
|
4294
4444
|
send: async (data) => {
|
|
@@ -4304,27 +4454,7 @@ var Mailer = class _Mailer {
|
|
|
4304
4454
|
}
|
|
4305
4455
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
4306
4456
|
async processWebhookBacklog() {
|
|
4307
|
-
|
|
4308
|
-
for (const evt of batch) {
|
|
4309
|
-
try {
|
|
4310
|
-
const normalized = evt.raw?.normalized;
|
|
4311
|
-
const details = normalized?.details ?? {};
|
|
4312
|
-
await applyWebhookEvent(
|
|
4313
|
-
{
|
|
4314
|
-
type: evt.normalizedType,
|
|
4315
|
-
providerEventId: evt.providerEventId,
|
|
4316
|
-
providerMessageId: evt.providerMessageId,
|
|
4317
|
-
email: evt.email,
|
|
4318
|
-
occurredAt: evt.occurredAt,
|
|
4319
|
-
details
|
|
4320
|
-
},
|
|
4321
|
-
this.runnerContext
|
|
4322
|
-
);
|
|
4323
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
4324
|
-
} catch (err) {
|
|
4325
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
4326
|
-
}
|
|
4327
|
-
}
|
|
4457
|
+
await processWebhookBacklog(this.runnerContext);
|
|
4328
4458
|
}
|
|
4329
4459
|
async stop() {
|
|
4330
4460
|
await this.queueDriver.close();
|
|
@@ -5053,7 +5183,7 @@ async function checkPostalAddress(mailer) {
|
|
|
5053
5183
|
label: "CAN-SPAM postal address",
|
|
5054
5184
|
severity: "warn",
|
|
5055
5185
|
message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
|
|
5056
|
-
hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference
|
|
5186
|
+
hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference the `{{senderAddress}}` render variable in your templates."
|
|
5057
5187
|
};
|
|
5058
5188
|
}
|
|
5059
5189
|
async function checkDoiTemplate(mailer) {
|
|
@@ -5347,19 +5477,30 @@ async function persistScore(ctx, input) {
|
|
|
5347
5477
|
}
|
|
5348
5478
|
async function evaluateMailTesterGate(ctx, input) {
|
|
5349
5479
|
const cfg = ctx.config.mailTester;
|
|
5350
|
-
if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
|
|
5480
|
+
if (!cfg?.apiKey) return { allowed: true, reason: null, score: null, code: null };
|
|
5351
5481
|
const minScore = cfg.minScore ?? 8;
|
|
5352
5482
|
const key = mailTesterContentKey(input);
|
|
5353
5483
|
const cached = await findCachedScore(ctx, key);
|
|
5354
|
-
if (!cached)
|
|
5484
|
+
if (!cached) {
|
|
5485
|
+
if (cfg.requireScore) {
|
|
5486
|
+
return {
|
|
5487
|
+
allowed: false,
|
|
5488
|
+
reason: "No Mail-Tester score for this exact content. Run a deliverability check before publishing.",
|
|
5489
|
+
score: null,
|
|
5490
|
+
code: "no_score"
|
|
5491
|
+
};
|
|
5492
|
+
}
|
|
5493
|
+
return { allowed: true, reason: null, score: null, code: null };
|
|
5494
|
+
}
|
|
5355
5495
|
if (cached.score < minScore) {
|
|
5356
5496
|
return {
|
|
5357
5497
|
allowed: false,
|
|
5358
5498
|
reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
|
|
5359
|
-
score: cached
|
|
5499
|
+
score: cached,
|
|
5500
|
+
code: "low_score"
|
|
5360
5501
|
};
|
|
5361
5502
|
}
|
|
5362
|
-
return { allowed: true, reason: null, score: cached };
|
|
5503
|
+
return { allowed: true, reason: null, score: cached, code: null };
|
|
5363
5504
|
}
|
|
5364
5505
|
|
|
5365
5506
|
// src/server/api/admin.ts
|
|
@@ -6353,6 +6494,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6353
6494
|
res.json({
|
|
6354
6495
|
configured,
|
|
6355
6496
|
minScore: cfg?.minScore ?? 8,
|
|
6497
|
+
requireScore: !!cfg?.requireScore,
|
|
6356
6498
|
cacheHours: cfg?.cacheHours ?? 24,
|
|
6357
6499
|
score: cached
|
|
6358
6500
|
});
|
|
@@ -6567,9 +6709,10 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6567
6709
|
if (!gate.allowed) {
|
|
6568
6710
|
return res.status(422).json({
|
|
6569
6711
|
error: "mail_tester_blocked",
|
|
6712
|
+
code: gate.code,
|
|
6570
6713
|
message: gate.reason,
|
|
6571
6714
|
score: gate.score,
|
|
6572
|
-
hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
|
|
6715
|
+
hint: gate.code === "no_score" ? "POST to `/templates/:slug/mail-tester-check`, poll `/mail-tester-result`, then publish \u2014 or POST `bypassMailTester: true` to publish anyway." : "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
|
|
6573
6716
|
});
|
|
6574
6717
|
}
|
|
6575
6718
|
}
|
|
@@ -7377,7 +7520,7 @@ var DEDUPE_POLICIES = [
|
|
|
7377
7520
|
];
|
|
7378
7521
|
|
|
7379
7522
|
// src/server/index.ts
|
|
7380
|
-
var VERSION = "0.
|
|
7523
|
+
var VERSION = "0.8.1";
|
|
7381
7524
|
|
|
7382
7525
|
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 };
|
|
7383
7526
|
//# sourceMappingURL=index.js.map
|