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.cjs
CHANGED
|
@@ -653,6 +653,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
653
653
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
654
654
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
655
655
|
{ key: { name: 1, occurredAt: -1 } },
|
|
656
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
656
657
|
{ key: { externalId: 1, name: 1 } }
|
|
657
658
|
]),
|
|
658
659
|
c.flows.createIndexes([
|
|
@@ -663,7 +664,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
663
664
|
c.flowRuns.createIndexes([
|
|
664
665
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
665
666
|
{ key: { externalId: 1, flowId: 1 } },
|
|
666
|
-
{ key: { flowId: 1, status: 1 } }
|
|
667
|
+
{ key: { flowId: 1, status: 1 } },
|
|
668
|
+
{
|
|
669
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
670
|
+
unique: true,
|
|
671
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
672
|
+
}
|
|
667
673
|
]),
|
|
668
674
|
c.templates.createIndexes([
|
|
669
675
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -906,7 +912,13 @@ var BullDriver = class _BullDriver {
|
|
|
906
912
|
constructor(bull, redis) {
|
|
907
913
|
this.bull = bull;
|
|
908
914
|
this.redis = redis;
|
|
909
|
-
const opts = {
|
|
915
|
+
const opts = {
|
|
916
|
+
connection: redis,
|
|
917
|
+
defaultJobOptions: {
|
|
918
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
919
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
920
|
+
}
|
|
921
|
+
};
|
|
910
922
|
this.bullQueues = {
|
|
911
923
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
912
924
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -924,7 +936,11 @@ var BullDriver = class _BullDriver {
|
|
|
924
936
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
925
937
|
"mailer-tick-repeat",
|
|
926
938
|
{ every: intervalSeconds * 1e3 },
|
|
927
|
-
{
|
|
939
|
+
{
|
|
940
|
+
name: "tick",
|
|
941
|
+
data: {},
|
|
942
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
943
|
+
}
|
|
928
944
|
);
|
|
929
945
|
}
|
|
930
946
|
async startWorkers(opts) {
|
|
@@ -1034,9 +1050,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1034
1050
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
1035
1051
|
);
|
|
1036
1052
|
}
|
|
1053
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
1037
1054
|
const backend = new backendMod.MongoBackend({
|
|
1038
1055
|
mongo: opts.db,
|
|
1039
|
-
collection:
|
|
1056
|
+
collection: collectionName
|
|
1040
1057
|
});
|
|
1041
1058
|
const agenda = new agendaMod.Agenda({
|
|
1042
1059
|
backend,
|
|
@@ -1045,13 +1062,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1045
1062
|
maxConcurrency: 50,
|
|
1046
1063
|
defaultConcurrency: 5
|
|
1047
1064
|
});
|
|
1048
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
1065
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
1049
1066
|
}
|
|
1050
1067
|
db;
|
|
1051
|
-
|
|
1068
|
+
collName;
|
|
1069
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
1052
1070
|
this.agenda = agenda;
|
|
1053
1071
|
this.agendaMod = agendaMod;
|
|
1054
1072
|
this.db = db;
|
|
1073
|
+
this.collName = collectionName;
|
|
1055
1074
|
this.queues = {
|
|
1056
1075
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
1057
1076
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -1084,10 +1103,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1084
1103
|
}
|
|
1085
1104
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
1086
1105
|
jobsCollection() {
|
|
1087
|
-
return this.db.collection(this.
|
|
1088
|
-
}
|
|
1089
|
-
collectionName() {
|
|
1090
|
-
return "_mailerJobs";
|
|
1106
|
+
return this.db.collection(this.collName);
|
|
1091
1107
|
}
|
|
1092
1108
|
async findPending(name, jobId) {
|
|
1093
1109
|
return this.jobsCollection().findOne({
|
|
@@ -1097,12 +1113,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1097
1113
|
});
|
|
1098
1114
|
}
|
|
1099
1115
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
1100
|
-
if (!this.started) {
|
|
1101
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
1102
|
-
}, { concurrency: 1 });
|
|
1103
|
-
await this.agenda.start();
|
|
1104
|
-
this.started = true;
|
|
1105
|
-
}
|
|
1106
1116
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
1107
1117
|
}
|
|
1108
1118
|
async startWorkers(opts) {
|
|
@@ -1205,6 +1215,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
1205
1215
|
|
|
1206
1216
|
// src/server/runner/triggers.ts
|
|
1207
1217
|
var BATCH_SIZE = 1e3;
|
|
1218
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
1208
1219
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
1209
1220
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
1210
1221
|
for (const flow of flows) {
|
|
@@ -1215,16 +1226,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
1215
1226
|
const eventName = flow.trigger.eventName;
|
|
1216
1227
|
if (!eventName) return;
|
|
1217
1228
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
1218
|
-
const
|
|
1229
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
1230
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
1219
1231
|
if (events.length === 0) return;
|
|
1220
1232
|
for (const event of events) {
|
|
1221
1233
|
await tryEnterFlow(flow, event, ctx);
|
|
1222
1234
|
}
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1235
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
1236
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
1237
|
+
await ctx.collections.flows.updateOne(
|
|
1238
|
+
{ _id: flow._id },
|
|
1239
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1228
1242
|
}
|
|
1229
1243
|
async function tryEnterFlow(flow, event, ctx) {
|
|
1230
1244
|
if (flow.trigger.once) {
|
|
@@ -1236,188 +1250,562 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
1236
1250
|
}
|
|
1237
1251
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
1238
1252
|
if (!sub || sub.status !== "subscribed") return;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1253
|
+
let result;
|
|
1254
|
+
try {
|
|
1255
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
1256
|
+
externalId: event.externalId,
|
|
1257
|
+
flowId: flow._id,
|
|
1258
|
+
flowSlug: flow.slug,
|
|
1259
|
+
flowVersion: flow.version,
|
|
1260
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
1261
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
1262
|
+
triggerDedupeKey: event.dedupeKey,
|
|
1263
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
1264
|
+
status: "active",
|
|
1265
|
+
currentStepIndex: 0,
|
|
1266
|
+
currentBranchPath: [],
|
|
1267
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
1268
|
+
attemptsForCurrentStep: 0,
|
|
1269
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
1270
|
+
exitedAt: null,
|
|
1271
|
+
exitReason: null,
|
|
1272
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1273
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1274
|
+
});
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
if (err?.code !== 11e3) throw err;
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1258
1279
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
1259
1280
|
}
|
|
1260
1281
|
|
|
1261
|
-
// src/server/
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
const
|
|
1265
|
-
if (
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1272
|
-
}
|
|
1273
|
-
if ("subscriptionStatus" in p) {
|
|
1274
|
-
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1275
|
-
return sub?.status === p.subscriptionStatus;
|
|
1276
|
-
}
|
|
1277
|
-
if ("hasFiredEvent" in p) {
|
|
1278
|
-
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1279
|
-
}
|
|
1280
|
-
if ("notHasFiredEvent" in p) {
|
|
1281
|
-
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1282
|
-
}
|
|
1283
|
-
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1284
|
-
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1285
|
-
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1286
|
-
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1287
|
-
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1288
|
-
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1289
|
-
if ("all" in p) {
|
|
1290
|
-
for (const sub of p.all) {
|
|
1291
|
-
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1292
|
-
}
|
|
1293
|
-
return true;
|
|
1294
|
-
}
|
|
1295
|
-
if ("any" in p) {
|
|
1296
|
-
for (const sub of p.any) {
|
|
1297
|
-
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1298
|
-
}
|
|
1299
|
-
return false;
|
|
1300
|
-
}
|
|
1301
|
-
if ("not" in p) {
|
|
1302
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
1282
|
+
// src/server/templates/sender-domain.ts
|
|
1283
|
+
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1284
|
+
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1285
|
+
const domain = extractDomain(fromEmail);
|
|
1286
|
+
if (!domain) {
|
|
1287
|
+
return {
|
|
1288
|
+
ok: false,
|
|
1289
|
+
code: "invalid_email",
|
|
1290
|
+
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1291
|
+
};
|
|
1303
1292
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1314
|
-
const filter = { externalId: ctx.contact.externalId };
|
|
1315
|
-
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1316
|
-
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1317
|
-
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1318
|
-
const lower = effectiveLowerBound(ctx, opts);
|
|
1319
|
-
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1320
|
-
if (!excludeBots) {
|
|
1321
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
1293
|
+
const entry = registry[domain];
|
|
1294
|
+
if (!entry) {
|
|
1295
|
+
const known = Object.keys(registry).join(", ");
|
|
1296
|
+
return {
|
|
1297
|
+
ok: false,
|
|
1298
|
+
code: "unregistered_domain",
|
|
1299
|
+
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1300
|
+
};
|
|
1322
1301
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
}
|
|
1330
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1331
|
-
if (hasHumanClick) n++;
|
|
1302
|
+
if (entry.kind === "both") return { ok: true };
|
|
1303
|
+
if (entry.kind !== templateKind) {
|
|
1304
|
+
return {
|
|
1305
|
+
ok: false,
|
|
1306
|
+
code: "wrong_kind",
|
|
1307
|
+
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1308
|
+
};
|
|
1332
1309
|
}
|
|
1333
|
-
return
|
|
1310
|
+
return { ok: true };
|
|
1334
1311
|
}
|
|
1335
|
-
function
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
if (
|
|
1339
|
-
|
|
1340
|
-
}
|
|
1341
|
-
return null;
|
|
1312
|
+
function extractDomain(email) {
|
|
1313
|
+
if (typeof email !== "string") return null;
|
|
1314
|
+
const at = email.lastIndexOf("@");
|
|
1315
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
1316
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
1342
1317
|
}
|
|
1343
1318
|
|
|
1344
|
-
// src/server/runner/
|
|
1345
|
-
var
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1319
|
+
// src/server/runner/health.ts
|
|
1320
|
+
var ZERO_COUNTERS = {
|
|
1321
|
+
sent: 0,
|
|
1322
|
+
delivered: 0,
|
|
1323
|
+
bounced: 0,
|
|
1324
|
+
hardBounced: 0,
|
|
1325
|
+
softBounced: 0,
|
|
1326
|
+
complained: 0,
|
|
1327
|
+
failedToSend: 0
|
|
1328
|
+
};
|
|
1329
|
+
var ZERO_RATES = {
|
|
1330
|
+
bounceRate: 0,
|
|
1331
|
+
hardBounceRate: 0,
|
|
1332
|
+
complaintRate: 0,
|
|
1333
|
+
failureRate: 0
|
|
1334
|
+
};
|
|
1335
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1336
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1337
|
+
const writes = [];
|
|
1338
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1339
|
+
if (dims) {
|
|
1340
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1341
|
+
if (domain) {
|
|
1342
|
+
const id = healthBucketId(domain, dims.kind);
|
|
1343
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1367
1344
|
}
|
|
1368
1345
|
}
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
function pickTimezone(window, contactTimezone) {
|
|
1372
|
-
const candidates = [
|
|
1373
|
-
window.useContactTimezone ? contactTimezone : void 0,
|
|
1374
|
-
window.timezone,
|
|
1375
|
-
"UTC"
|
|
1376
|
-
];
|
|
1377
|
-
for (const tz of candidates) {
|
|
1378
|
-
if (tz && isValidTimezone(tz)) return tz;
|
|
1379
|
-
}
|
|
1380
|
-
return "UTC";
|
|
1346
|
+
await Promise.all(writes);
|
|
1381
1347
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1348
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1349
|
+
await ctx.collections.health.updateOne(
|
|
1350
|
+
{ _id },
|
|
1351
|
+
{
|
|
1352
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
1353
|
+
$setOnInsert: {
|
|
1354
|
+
_id,
|
|
1355
|
+
senderDomain,
|
|
1356
|
+
kind,
|
|
1357
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1358
|
+
windowDurationMs: windowMs,
|
|
1359
|
+
status: "healthy",
|
|
1360
|
+
trippedAt: null,
|
|
1361
|
+
trippedReason: null,
|
|
1362
|
+
manuallyResumedAt: null,
|
|
1363
|
+
rates: { ...ZERO_RATES }
|
|
1364
|
+
},
|
|
1365
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1366
|
+
},
|
|
1367
|
+
{ upsert: true }
|
|
1368
|
+
);
|
|
1394
1369
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
day: "2-digit",
|
|
1404
|
-
hour: "2-digit",
|
|
1405
|
-
minute: "2-digit",
|
|
1406
|
-
second: "2-digit",
|
|
1407
|
-
weekday: "short",
|
|
1408
|
-
hour12: false
|
|
1409
|
-
});
|
|
1410
|
-
partFormatters.set(tz, f);
|
|
1370
|
+
async function evaluateHealth(ctx) {
|
|
1371
|
+
const cb = ctx.config.circuitBreaker;
|
|
1372
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1373
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
1374
|
+
if (docs.length === 0) return;
|
|
1375
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1376
|
+
if (!hasAgg) {
|
|
1377
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1411
1378
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1379
|
+
for (const doc of docs) {
|
|
1380
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1381
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1382
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1383
|
+
await ctx.collections.health.updateOne(
|
|
1384
|
+
{ _id: doc._id },
|
|
1385
|
+
{
|
|
1386
|
+
$set: {
|
|
1387
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1388
|
+
windowDurationMs: windowMs,
|
|
1389
|
+
counters: { ...ZERO_COUNTERS },
|
|
1390
|
+
rates: { ...ZERO_RATES },
|
|
1391
|
+
status: "healthy",
|
|
1392
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
);
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const c = doc.counters;
|
|
1399
|
+
const total = c.sent || 1;
|
|
1400
|
+
const rates = {
|
|
1401
|
+
bounceRate: c.bounced / total,
|
|
1402
|
+
hardBounceRate: c.hardBounced / total,
|
|
1403
|
+
complaintRate: c.complained / total,
|
|
1404
|
+
failureRate: c.failedToSend / total
|
|
1405
|
+
};
|
|
1406
|
+
await ctx.collections.health.updateOne(
|
|
1407
|
+
{ _id: doc._id },
|
|
1408
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1409
|
+
);
|
|
1410
|
+
if (isAgg) continue;
|
|
1411
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1412
|
+
if (doc.status === "tripped") continue;
|
|
1413
|
+
let trippedReason = null;
|
|
1414
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1415
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1416
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1417
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1418
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1419
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1420
|
+
}
|
|
1421
|
+
if (trippedReason) {
|
|
1422
|
+
const result = await ctx.collections.health.updateOne(
|
|
1423
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1424
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1425
|
+
);
|
|
1426
|
+
if (result.modifiedCount > 0) {
|
|
1427
|
+
if (ctx.audit) {
|
|
1428
|
+
try {
|
|
1429
|
+
await ctx.audit({
|
|
1430
|
+
actor: "system:circuit-breaker",
|
|
1431
|
+
action: "health.trip",
|
|
1432
|
+
resource: {
|
|
1433
|
+
collection: "mailer_health",
|
|
1434
|
+
id: String(doc._id),
|
|
1435
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1436
|
+
},
|
|
1437
|
+
diffSummary: trippedReason
|
|
1438
|
+
});
|
|
1439
|
+
} catch {
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
1443
|
+
try {
|
|
1444
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
1445
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1446
|
+
rates
|
|
1447
|
+
});
|
|
1448
|
+
} catch {
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1455
|
+
if (doc.status !== "degraded") {
|
|
1456
|
+
await ctx.collections.health.updateOne(
|
|
1457
|
+
{ _id: doc._id },
|
|
1458
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
} else if (doc.status === "degraded") {
|
|
1462
|
+
await ctx.collections.health.updateOne(
|
|
1463
|
+
{ _id: doc._id },
|
|
1464
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
1470
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
1471
|
+
const id = healthBucketId(domain, kind);
|
|
1472
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
1473
|
+
}
|
|
1474
|
+
function effectiveOverallStatus(docs) {
|
|
1475
|
+
const buckets = docs.filter((d) => typeof d._id === "string" && d._id.startsWith("d:"));
|
|
1476
|
+
if (buckets.length === 0) {
|
|
1477
|
+
return docs.length === 0 ? null : "healthy";
|
|
1478
|
+
}
|
|
1479
|
+
if (buckets.some((d) => d.status === "tripped")) return "tripped";
|
|
1480
|
+
if (buckets.some((d) => d.status === "degraded")) return "degraded";
|
|
1481
|
+
return "healthy";
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/server/runner/webhook.ts
|
|
1485
|
+
function dimsFromSend(send) {
|
|
1486
|
+
if (!send) return null;
|
|
1487
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
1488
|
+
}
|
|
1489
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
1490
|
+
const filter = { processed: false };
|
|
1491
|
+
if (opts.olderThanMs) {
|
|
1492
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
1493
|
+
}
|
|
1494
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
1495
|
+
for (const evt of batch) {
|
|
1496
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
1497
|
+
{ _id: evt._id, processed: false },
|
|
1498
|
+
{ $set: { processed: true } }
|
|
1499
|
+
);
|
|
1500
|
+
if (!claimed) continue;
|
|
1501
|
+
try {
|
|
1502
|
+
const normalized = evt.raw?.normalized;
|
|
1503
|
+
const details = normalized?.details ?? {};
|
|
1504
|
+
await applyWebhookEvent(
|
|
1505
|
+
{
|
|
1506
|
+
type: evt.normalizedType,
|
|
1507
|
+
providerEventId: evt.providerEventId,
|
|
1508
|
+
providerMessageId: evt.providerMessageId,
|
|
1509
|
+
email: evt.email,
|
|
1510
|
+
occurredAt: evt.occurredAt,
|
|
1511
|
+
details
|
|
1512
|
+
},
|
|
1513
|
+
ctx
|
|
1514
|
+
);
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
async function applyWebhookEvent(event, ctx) {
|
|
1521
|
+
const send = await ctx.collections.sends.findOne(
|
|
1522
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
1523
|
+
{ sort: { queuedAt: -1 } }
|
|
1524
|
+
);
|
|
1525
|
+
switch (event.type) {
|
|
1526
|
+
case "delivered":
|
|
1527
|
+
if (send) {
|
|
1528
|
+
await ctx.collections.sends.updateOne(
|
|
1529
|
+
{ _id: send._id },
|
|
1530
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
1534
|
+
break;
|
|
1535
|
+
case "open":
|
|
1536
|
+
if (send) {
|
|
1537
|
+
await ctx.collections.sends.updateOne(
|
|
1538
|
+
{ _id: send._id },
|
|
1539
|
+
{
|
|
1540
|
+
$set: {
|
|
1541
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
1542
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
1543
|
+
},
|
|
1544
|
+
$inc: { openCount: 1 }
|
|
1545
|
+
}
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
break;
|
|
1549
|
+
case "click":
|
|
1550
|
+
if (send) {
|
|
1551
|
+
await ctx.collections.sends.updateOne(
|
|
1552
|
+
{ _id: send._id },
|
|
1553
|
+
{
|
|
1554
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
1555
|
+
$inc: { clickCount: 1 },
|
|
1556
|
+
$push: {
|
|
1557
|
+
clickedLinks: {
|
|
1558
|
+
url: event.details.clickedUrl ?? "",
|
|
1559
|
+
linkId: "",
|
|
1560
|
+
clickedAt: event.occurredAt
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
break;
|
|
1567
|
+
case "bounce": {
|
|
1568
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
1569
|
+
if (send) {
|
|
1570
|
+
await ctx.collections.sends.updateOne(
|
|
1571
|
+
{ _id: send._id },
|
|
1572
|
+
{
|
|
1573
|
+
$set: {
|
|
1574
|
+
status: "bounced",
|
|
1575
|
+
bounceType,
|
|
1576
|
+
bounceReason: event.details.bounceReason ?? null
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
if (bounceType === "hard") {
|
|
1582
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
1583
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1584
|
+
{ emailAtSubscribe: event.email },
|
|
1585
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
{
|
|
1589
|
+
const dims = dimsFromSend(send);
|
|
1590
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
1591
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
1592
|
+
}
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
case "complaint":
|
|
1596
|
+
case "spam_report":
|
|
1597
|
+
if (send) {
|
|
1598
|
+
await ctx.collections.sends.updateOne(
|
|
1599
|
+
{ _id: send._id },
|
|
1600
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
1604
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1605
|
+
{ emailAtSubscribe: event.email },
|
|
1606
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1607
|
+
);
|
|
1608
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
1609
|
+
break;
|
|
1610
|
+
case "unsubscribe":
|
|
1611
|
+
if (send) {
|
|
1612
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
1613
|
+
}
|
|
1614
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
1615
|
+
await ctx.collections.subscriptions.updateOne(
|
|
1616
|
+
{ emailAtSubscribe: event.email },
|
|
1617
|
+
{
|
|
1618
|
+
$set: {
|
|
1619
|
+
status: "unsubscribed",
|
|
1620
|
+
unsubscribedAt: event.occurredAt,
|
|
1621
|
+
unsubscribeReason: "user_request",
|
|
1622
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
);
|
|
1626
|
+
break;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
1630
|
+
const normalized = email.toLowerCase();
|
|
1631
|
+
await ctx.collections.suppressions.updateOne(
|
|
1632
|
+
{ email: normalized, scope },
|
|
1633
|
+
{
|
|
1634
|
+
$setOnInsert: {
|
|
1635
|
+
email: normalized,
|
|
1636
|
+
emailHash: sha256Hex(normalized),
|
|
1637
|
+
scope,
|
|
1638
|
+
reason,
|
|
1639
|
+
source: "provider_webhook",
|
|
1640
|
+
notes: null,
|
|
1641
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
1642
|
+
expiresAt: null
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
{ upsert: true }
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// src/server/runner/predicate.ts
|
|
1650
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1651
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
1652
|
+
const p = predicate;
|
|
1653
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
1654
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
1655
|
+
if ("fieldEquals" in p) {
|
|
1656
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
1657
|
+
}
|
|
1658
|
+
if ("fieldExists" in p) {
|
|
1659
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
1660
|
+
}
|
|
1661
|
+
if ("subscriptionStatus" in p) {
|
|
1662
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
1663
|
+
return sub?.status === p.subscriptionStatus;
|
|
1664
|
+
}
|
|
1665
|
+
if ("hasFiredEvent" in p) {
|
|
1666
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
1667
|
+
}
|
|
1668
|
+
if ("notHasFiredEvent" in p) {
|
|
1669
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
1670
|
+
}
|
|
1671
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
1672
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
1673
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
1674
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
1675
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
1676
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
1677
|
+
if ("all" in p) {
|
|
1678
|
+
for (const sub of p.all) {
|
|
1679
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
1680
|
+
}
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
if ("any" in p) {
|
|
1684
|
+
for (const sub of p.any) {
|
|
1685
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
1686
|
+
}
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
if ("not" in p) {
|
|
1690
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1694
|
+
async function hasEvent(ctx, name, opts) {
|
|
1695
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
1696
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1697
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
1698
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
1699
|
+
return !!found;
|
|
1700
|
+
}
|
|
1701
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
1702
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
1703
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
1704
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
1705
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
1706
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
1707
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
1708
|
+
if (!excludeBots) {
|
|
1709
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
1710
|
+
}
|
|
1711
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1712
|
+
let n = 0;
|
|
1713
|
+
for (const s of docs) {
|
|
1714
|
+
if (kind === "opened") {
|
|
1715
|
+
n++;
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1719
|
+
if (hasHumanClick) n++;
|
|
1720
|
+
}
|
|
1721
|
+
return n;
|
|
1722
|
+
}
|
|
1723
|
+
function effectiveLowerBound(ctx, opts) {
|
|
1724
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1725
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
1726
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
1727
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
1728
|
+
}
|
|
1729
|
+
return null;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// src/server/runner/delivery-window.ts
|
|
1733
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
1734
|
+
function computeDeliveryTime(now, window, contactTimezone) {
|
|
1735
|
+
const tz = pickTimezone(window, contactTimezone);
|
|
1736
|
+
let candidate = now;
|
|
1737
|
+
if (window.timeOfDay) {
|
|
1738
|
+
const [hh, mm] = window.timeOfDay.split(":").map(Number);
|
|
1739
|
+
const local = localParts(candidate, tz);
|
|
1740
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
1741
|
+
if (candidate.getTime() < todaySlot.getTime()) {
|
|
1742
|
+
candidate = todaySlot;
|
|
1743
|
+
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
1744
|
+
const next = addLocalDays(local, 1);
|
|
1745
|
+
candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (window.weekdaysOnly) {
|
|
1749
|
+
for (let guard = 0; guard < 3; guard++) {
|
|
1750
|
+
const local = localParts(candidate, tz);
|
|
1751
|
+
if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
|
|
1752
|
+
const shift = local.weekday === "Sat" ? 2 : 1;
|
|
1753
|
+
const moved = addLocalDays(local, shift);
|
|
1754
|
+
candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
return candidate;
|
|
1758
|
+
}
|
|
1759
|
+
function pickTimezone(window, contactTimezone) {
|
|
1760
|
+
const candidates = [
|
|
1761
|
+
window.useContactTimezone ? contactTimezone : void 0,
|
|
1762
|
+
window.timezone,
|
|
1763
|
+
"UTC"
|
|
1764
|
+
];
|
|
1765
|
+
for (const tz of candidates) {
|
|
1766
|
+
if (tz && isValidTimezone(tz)) return tz;
|
|
1767
|
+
}
|
|
1768
|
+
return "UTC";
|
|
1769
|
+
}
|
|
1770
|
+
var validatedZones = /* @__PURE__ */ new Map();
|
|
1771
|
+
function isValidTimezone(tz) {
|
|
1772
|
+
const cached = validatedZones.get(tz);
|
|
1773
|
+
if (cached !== void 0) return cached;
|
|
1774
|
+
let ok = true;
|
|
1775
|
+
try {
|
|
1776
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
1777
|
+
} catch {
|
|
1778
|
+
ok = false;
|
|
1779
|
+
}
|
|
1780
|
+
validatedZones.set(tz, ok);
|
|
1781
|
+
return ok;
|
|
1782
|
+
}
|
|
1783
|
+
var partFormatters = /* @__PURE__ */ new Map();
|
|
1784
|
+
function formatterFor(tz) {
|
|
1785
|
+
let f = partFormatters.get(tz);
|
|
1786
|
+
if (!f) {
|
|
1787
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
1788
|
+
timeZone: tz,
|
|
1789
|
+
year: "numeric",
|
|
1790
|
+
month: "2-digit",
|
|
1791
|
+
day: "2-digit",
|
|
1792
|
+
hour: "2-digit",
|
|
1793
|
+
minute: "2-digit",
|
|
1794
|
+
second: "2-digit",
|
|
1795
|
+
weekday: "short",
|
|
1796
|
+
hour12: false
|
|
1797
|
+
});
|
|
1798
|
+
partFormatters.set(tz, f);
|
|
1799
|
+
}
|
|
1800
|
+
return f;
|
|
1801
|
+
}
|
|
1802
|
+
function localParts(date, tz) {
|
|
1803
|
+
const parts = {};
|
|
1804
|
+
for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
|
|
1805
|
+
return {
|
|
1806
|
+
y: Number(parts.year),
|
|
1807
|
+
mo: Number(parts.month),
|
|
1808
|
+
d: Number(parts.day),
|
|
1421
1809
|
hh: Number(parts.hour) % 24,
|
|
1422
1810
|
// Intl emits '24' for midnight in some locales
|
|
1423
1811
|
mi: Number(parts.minute),
|
|
@@ -1487,7 +1875,8 @@ function applyTracking(html, opts) {
|
|
|
1487
1875
|
const seen = /* @__PURE__ */ new Map();
|
|
1488
1876
|
let out = html;
|
|
1489
1877
|
if (opts.trackClicks) {
|
|
1490
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
1878
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
1879
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
1491
1880
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
1492
1881
|
let linkId = seen.get(url);
|
|
1493
1882
|
if (!linkId) {
|
|
@@ -1519,6 +1908,29 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
1519
1908
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
1520
1909
|
return false;
|
|
1521
1910
|
}
|
|
1911
|
+
var NAMED_ENTITIES = {
|
|
1912
|
+
amp: "&",
|
|
1913
|
+
lt: "<",
|
|
1914
|
+
gt: ">",
|
|
1915
|
+
quot: '"',
|
|
1916
|
+
apos: "'",
|
|
1917
|
+
nbsp: "\xA0"
|
|
1918
|
+
};
|
|
1919
|
+
function decodeHtmlEntities(input) {
|
|
1920
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
1921
|
+
if (body[0] === "#") {
|
|
1922
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
1923
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
1924
|
+
try {
|
|
1925
|
+
return String.fromCodePoint(code);
|
|
1926
|
+
} catch {
|
|
1927
|
+
return match;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
1931
|
+
return named ?? match;
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1522
1934
|
function shortHash(input) {
|
|
1523
1935
|
return crypto2__default.default.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
1524
1936
|
}
|
|
@@ -1531,262 +1943,60 @@ function makeHandlebars(extra) {
|
|
|
1531
1943
|
hb.registerHelper("gte", (a, b) => a >= b);
|
|
1532
1944
|
hb.registerHelper("lte", (a, b) => a <= b);
|
|
1533
1945
|
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
1534
|
-
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1535
|
-
hb.registerHelper("not", (a) => !a);
|
|
1536
|
-
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1537
|
-
if (!value) return "";
|
|
1538
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
1539
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
1540
|
-
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1541
|
-
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1542
|
-
return d.toISOString().slice(0, 10);
|
|
1543
|
-
});
|
|
1544
|
-
hb.registerHelper("formatNumber", (n) => {
|
|
1545
|
-
const v = Number(n);
|
|
1546
|
-
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1547
|
-
});
|
|
1548
|
-
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1549
|
-
const v = Number(cents);
|
|
1550
|
-
if (!Number.isFinite(v)) return "";
|
|
1551
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1552
|
-
});
|
|
1553
|
-
hb.registerHelper(
|
|
1554
|
-
"pluralize",
|
|
1555
|
-
(n, one, many) => Number(n) === 1 ? one : many
|
|
1556
|
-
);
|
|
1557
|
-
if (extra) {
|
|
1558
|
-
for (const [name, fn] of Object.entries(extra)) {
|
|
1559
|
-
hb.registerHelper(name, fn);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
return hb;
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// src/server/runner/send.ts
|
|
1566
|
-
init_vars();
|
|
1567
|
-
|
|
1568
|
-
// src/server/runner/suppression.ts
|
|
1569
|
-
var SCOPES_BY_KIND = {
|
|
1570
|
-
marketing: ["all", "marketing"],
|
|
1571
|
-
transactional: ["all", "transactional"]
|
|
1572
|
-
};
|
|
1573
|
-
async function isSuppressed(collections, email, kind) {
|
|
1574
|
-
const normalized = email.toLowerCase();
|
|
1575
|
-
const allowed = SCOPES_BY_KIND[kind];
|
|
1576
|
-
const byEmail = await collections.suppressions.findOne({
|
|
1577
|
-
email: normalized,
|
|
1578
|
-
scope: { $in: allowed },
|
|
1579
|
-
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
1580
|
-
});
|
|
1581
|
-
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
1582
|
-
const hashed = await collections.suppressions.findOne({
|
|
1583
|
-
emailHash: sha256Hex(normalized),
|
|
1584
|
-
scope: { $in: allowed }
|
|
1585
|
-
});
|
|
1586
|
-
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
1587
|
-
return { suppressed: false };
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
// src/server/templates/sender-domain.ts
|
|
1591
|
-
function validateSenderDomain(fromEmail, templateKind, registry) {
|
|
1592
|
-
if (!registry || Object.keys(registry).length === 0) return { ok: true };
|
|
1593
|
-
const domain = extractDomain(fromEmail);
|
|
1594
|
-
if (!domain) {
|
|
1595
|
-
return {
|
|
1596
|
-
ok: false,
|
|
1597
|
-
code: "invalid_email",
|
|
1598
|
-
reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
|
|
1599
|
-
};
|
|
1600
|
-
}
|
|
1601
|
-
const entry = registry[domain];
|
|
1602
|
-
if (!entry) {
|
|
1603
|
-
const known = Object.keys(registry).join(", ");
|
|
1604
|
-
return {
|
|
1605
|
-
ok: false,
|
|
1606
|
-
code: "unregistered_domain",
|
|
1607
|
-
reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
|
|
1608
|
-
};
|
|
1609
|
-
}
|
|
1610
|
-
if (entry.kind === "both") return { ok: true };
|
|
1611
|
-
if (entry.kind !== templateKind) {
|
|
1612
|
-
return {
|
|
1613
|
-
ok: false,
|
|
1614
|
-
code: "wrong_kind",
|
|
1615
|
-
reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
|
|
1616
|
-
};
|
|
1617
|
-
}
|
|
1618
|
-
return { ok: true };
|
|
1619
|
-
}
|
|
1620
|
-
function extractDomain(email) {
|
|
1621
|
-
if (typeof email !== "string") return null;
|
|
1622
|
-
const at = email.lastIndexOf("@");
|
|
1623
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
1624
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
// src/server/runner/health.ts
|
|
1628
|
-
var ZERO_COUNTERS = {
|
|
1629
|
-
sent: 0,
|
|
1630
|
-
delivered: 0,
|
|
1631
|
-
bounced: 0,
|
|
1632
|
-
hardBounced: 0,
|
|
1633
|
-
softBounced: 0,
|
|
1634
|
-
complained: 0,
|
|
1635
|
-
failedToSend: 0
|
|
1636
|
-
};
|
|
1637
|
-
var ZERO_RATES = {
|
|
1638
|
-
bounceRate: 0,
|
|
1639
|
-
hardBounceRate: 0,
|
|
1640
|
-
complaintRate: 0,
|
|
1641
|
-
failureRate: 0
|
|
1642
|
-
};
|
|
1643
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
1644
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
1645
|
-
const writes = [];
|
|
1646
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
1647
|
-
if (dims) {
|
|
1648
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
1649
|
-
if (domain) {
|
|
1650
|
-
const id = healthBucketId(domain, dims.kind);
|
|
1651
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
await Promise.all(writes);
|
|
1655
|
-
}
|
|
1656
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
1657
|
-
await ctx.collections.health.updateOne(
|
|
1658
|
-
{ _id },
|
|
1659
|
-
{
|
|
1660
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
1661
|
-
$setOnInsert: {
|
|
1662
|
-
_id,
|
|
1663
|
-
senderDomain,
|
|
1664
|
-
kind,
|
|
1665
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1666
|
-
windowDurationMs: windowMs,
|
|
1667
|
-
status: "healthy",
|
|
1668
|
-
trippedAt: null,
|
|
1669
|
-
trippedReason: null,
|
|
1670
|
-
manuallyResumedAt: null,
|
|
1671
|
-
rates: { ...ZERO_RATES }
|
|
1672
|
-
},
|
|
1673
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1674
|
-
},
|
|
1675
|
-
{ upsert: true }
|
|
1676
|
-
);
|
|
1677
|
-
}
|
|
1678
|
-
async function evaluateHealth(ctx) {
|
|
1679
|
-
const cb = ctx.config.circuitBreaker;
|
|
1680
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
1681
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
1682
|
-
if (docs.length === 0) return;
|
|
1683
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
1684
|
-
if (!hasAgg) {
|
|
1685
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
1686
|
-
}
|
|
1687
|
-
for (const doc of docs) {
|
|
1688
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
1689
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
1690
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
1691
|
-
await ctx.collections.health.updateOne(
|
|
1692
|
-
{ _id: doc._id },
|
|
1693
|
-
{
|
|
1694
|
-
$set: {
|
|
1695
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
1696
|
-
windowDurationMs: windowMs,
|
|
1697
|
-
counters: { ...ZERO_COUNTERS },
|
|
1698
|
-
rates: { ...ZERO_RATES },
|
|
1699
|
-
status: "healthy",
|
|
1700
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
);
|
|
1704
|
-
continue;
|
|
1705
|
-
}
|
|
1706
|
-
const c = doc.counters;
|
|
1707
|
-
const total = c.sent || 1;
|
|
1708
|
-
const rates = {
|
|
1709
|
-
bounceRate: c.bounced / total,
|
|
1710
|
-
hardBounceRate: c.hardBounced / total,
|
|
1711
|
-
complaintRate: c.complained / total,
|
|
1712
|
-
failureRate: c.failedToSend / total
|
|
1713
|
-
};
|
|
1714
|
-
await ctx.collections.health.updateOne(
|
|
1715
|
-
{ _id: doc._id },
|
|
1716
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1717
|
-
);
|
|
1718
|
-
if (isAgg) continue;
|
|
1719
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
1720
|
-
if (doc.status === "tripped") continue;
|
|
1721
|
-
let trippedReason = null;
|
|
1722
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
1723
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
1724
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
1725
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
1726
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
1727
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
1728
|
-
}
|
|
1729
|
-
if (trippedReason) {
|
|
1730
|
-
const result = await ctx.collections.health.updateOne(
|
|
1731
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
1732
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1733
|
-
);
|
|
1734
|
-
if (result.modifiedCount > 0) {
|
|
1735
|
-
if (ctx.audit) {
|
|
1736
|
-
try {
|
|
1737
|
-
await ctx.audit({
|
|
1738
|
-
actor: "system:circuit-breaker",
|
|
1739
|
-
action: "health.trip",
|
|
1740
|
-
resource: {
|
|
1741
|
-
collection: "mailer_health",
|
|
1742
|
-
id: String(doc._id),
|
|
1743
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
1744
|
-
},
|
|
1745
|
-
diffSummary: trippedReason
|
|
1746
|
-
});
|
|
1747
|
-
} catch {
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
1751
|
-
try {
|
|
1752
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
1753
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
1754
|
-
rates
|
|
1755
|
-
});
|
|
1756
|
-
} catch {
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
continue;
|
|
1761
|
-
}
|
|
1762
|
-
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
1763
|
-
if (doc.status !== "degraded") {
|
|
1764
|
-
await ctx.collections.health.updateOne(
|
|
1765
|
-
{ _id: doc._id },
|
|
1766
|
-
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
} else if (doc.status === "degraded") {
|
|
1770
|
-
await ctx.collections.health.updateOne(
|
|
1771
|
-
{ _id: doc._id },
|
|
1772
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1773
|
-
);
|
|
1946
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
1947
|
+
hb.registerHelper("not", (a) => !a);
|
|
1948
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
1949
|
+
if (!value) return "";
|
|
1950
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
1951
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
1952
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
1953
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
1954
|
+
return d.toISOString().slice(0, 10);
|
|
1955
|
+
});
|
|
1956
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
1957
|
+
const v = Number(n);
|
|
1958
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
1959
|
+
});
|
|
1960
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
1961
|
+
const v = Number(cents);
|
|
1962
|
+
if (!Number.isFinite(v)) return "";
|
|
1963
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
1964
|
+
});
|
|
1965
|
+
hb.registerHelper(
|
|
1966
|
+
"pluralize",
|
|
1967
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
1968
|
+
);
|
|
1969
|
+
if (extra) {
|
|
1970
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
1971
|
+
hb.registerHelper(name, fn);
|
|
1774
1972
|
}
|
|
1775
1973
|
}
|
|
1974
|
+
return hb;
|
|
1776
1975
|
}
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1976
|
+
|
|
1977
|
+
// src/server/runner/send.ts
|
|
1978
|
+
init_vars();
|
|
1979
|
+
|
|
1980
|
+
// src/server/runner/suppression.ts
|
|
1981
|
+
var SCOPES_BY_KIND = {
|
|
1982
|
+
marketing: ["all", "marketing"],
|
|
1983
|
+
transactional: ["all", "transactional"]
|
|
1984
|
+
};
|
|
1985
|
+
async function isSuppressed(collections, email, kind) {
|
|
1986
|
+
const normalized = email.toLowerCase();
|
|
1987
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
1988
|
+
const byEmail = await collections.suppressions.findOne({
|
|
1989
|
+
email: normalized,
|
|
1990
|
+
scope: { $in: allowed },
|
|
1991
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
1992
|
+
});
|
|
1993
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
1994
|
+
const hashed = await collections.suppressions.findOne({
|
|
1995
|
+
emailHash: sha256Hex(normalized),
|
|
1996
|
+
scope: { $in: allowed }
|
|
1997
|
+
});
|
|
1998
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
1999
|
+
return { suppressed: false };
|
|
1790
2000
|
}
|
|
1791
2001
|
|
|
1792
2002
|
// src/server/runner/send.ts
|
|
@@ -1854,9 +2064,11 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
1854
2064
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
1855
2065
|
}
|
|
1856
2066
|
async function dispatchSend(sendId, ctx) {
|
|
1857
|
-
const send = await ctx.collections.sends.
|
|
2067
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
2068
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
2069
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2070
|
+
);
|
|
1858
2071
|
if (!send) return;
|
|
1859
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
1860
2072
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
1861
2073
|
if (!template) {
|
|
1862
2074
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -1873,6 +2085,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1873
2085
|
if (send.kind === "marketing") {
|
|
1874
2086
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
1875
2087
|
if (bucket?.status === "tripped") {
|
|
2088
|
+
await ctx.collections.sends.updateOne(
|
|
2089
|
+
{ _id: send._id },
|
|
2090
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2091
|
+
);
|
|
1876
2092
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
1877
2093
|
return;
|
|
1878
2094
|
}
|
|
@@ -1932,6 +2148,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1932
2148
|
await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
|
|
1933
2149
|
return;
|
|
1934
2150
|
}
|
|
2151
|
+
const headers = send.kind === "marketing" ? {
|
|
2152
|
+
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
2153
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
2154
|
+
} : {};
|
|
1935
2155
|
try {
|
|
1936
2156
|
const result = await provider.send({
|
|
1937
2157
|
to: send.emailAtSend,
|
|
@@ -1941,10 +2161,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
1941
2161
|
subject: rendered.subject,
|
|
1942
2162
|
html: tracking.html,
|
|
1943
2163
|
text: rendered.plainText,
|
|
1944
|
-
headers
|
|
1945
|
-
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
1946
|
-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
1947
|
-
},
|
|
2164
|
+
headers,
|
|
1948
2165
|
messageMeta: { sendId: String(send._id) }
|
|
1949
2166
|
});
|
|
1950
2167
|
await ctx.collections.sends.updateOne(
|
|
@@ -2120,7 +2337,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2120
2337
|
await ctx.queues.advance.add(
|
|
2121
2338
|
"advance",
|
|
2122
2339
|
{ flowRunId: String(run._id) },
|
|
2123
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
2340
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
2124
2341
|
);
|
|
2125
2342
|
}
|
|
2126
2343
|
async function handleCondition(run, step, contact, ctx) {
|
|
@@ -2232,7 +2449,7 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
2232
2449
|
await ctx.queues.advance.add(
|
|
2233
2450
|
"advance",
|
|
2234
2451
|
{ flowRunId: String(run._id) },
|
|
2235
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2452
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
2236
2453
|
);
|
|
2237
2454
|
}
|
|
2238
2455
|
}
|
|
@@ -2261,7 +2478,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
2261
2478
|
{ flowRunId: String(run._id) },
|
|
2262
2479
|
{
|
|
2263
2480
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
2264
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2481
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
2265
2482
|
}
|
|
2266
2483
|
);
|
|
2267
2484
|
}
|
|
@@ -2316,13 +2533,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
2316
2533
|
let arr = steps;
|
|
2317
2534
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
2318
2535
|
const parentIndex = branchPath[i];
|
|
2319
|
-
const
|
|
2536
|
+
const branchKey2 = branchPath[i + 1];
|
|
2320
2537
|
const parent = arr[parentIndex];
|
|
2321
2538
|
if (!parent || parent.type !== "branch") return null;
|
|
2322
|
-
arr =
|
|
2539
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
2323
2540
|
}
|
|
2324
2541
|
return arr[currentStepIndex] ?? null;
|
|
2325
2542
|
}
|
|
2543
|
+
function branchKey(run) {
|
|
2544
|
+
return run.currentBranchPath.join("_");
|
|
2545
|
+
}
|
|
2326
2546
|
function unitToMs(value, unit) {
|
|
2327
2547
|
const m = 6e4;
|
|
2328
2548
|
switch (unit) {
|
|
@@ -2349,6 +2569,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
2349
2569
|
}
|
|
2350
2570
|
}
|
|
2351
2571
|
}
|
|
2572
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
2352
2573
|
async function processScheduledBroadcasts(ctx) {
|
|
2353
2574
|
const now = /* @__PURE__ */ new Date();
|
|
2354
2575
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -2359,15 +2580,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
2359
2580
|
{ returnDocument: "after" }
|
|
2360
2581
|
);
|
|
2361
2582
|
if (!claimed) continue;
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2583
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
2587
|
+
if (ctx.config.queue.driver === "noop") {
|
|
2588
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
await ctx.queues.advance.add(
|
|
2592
|
+
"advance",
|
|
2593
|
+
{ broadcastId: String(broadcast._id) },
|
|
2594
|
+
{
|
|
2595
|
+
attempts: 3,
|
|
2596
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
2597
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
2370
2598
|
}
|
|
2599
|
+
);
|
|
2600
|
+
}
|
|
2601
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
2602
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
2603
|
+
if (!broadcast) return;
|
|
2604
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
2605
|
+
}
|
|
2606
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
2607
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
2608
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
2609
|
+
for (const b of stalled) {
|
|
2610
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2611
|
+
{ _id: b._id },
|
|
2612
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2613
|
+
);
|
|
2614
|
+
await startBroadcastDispatch(b, ctx);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
2618
|
+
try {
|
|
2619
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
2620
|
+
} catch (err) {
|
|
2621
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
2622
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2623
|
+
{ _id: broadcast._id },
|
|
2624
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
2625
|
+
);
|
|
2371
2626
|
}
|
|
2372
2627
|
}
|
|
2373
2628
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -2388,11 +2643,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
2388
2643
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
2389
2644
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
2390
2645
|
for (; ; ) {
|
|
2646
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2647
|
+
{ _id: broadcast._id },
|
|
2648
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2649
|
+
);
|
|
2391
2650
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
2392
2651
|
if (page.contacts.length === 0) break;
|
|
2393
2652
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
2394
2653
|
if (eligible.length > 0) {
|
|
2395
2654
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
2655
|
+
await ctx.collections.broadcasts.updateOne(
|
|
2656
|
+
{ _id: broadcast._id },
|
|
2657
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
2658
|
+
);
|
|
2396
2659
|
await sleep(2e3);
|
|
2397
2660
|
}
|
|
2398
2661
|
const sendDocs = await Promise.all(
|
|
@@ -2515,7 +2778,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2515
2778
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
2516
2779
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
2517
2780
|
if (respectTimezone && contact.timezone) {
|
|
2518
|
-
|
|
2781
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
2782
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
2519
2783
|
}
|
|
2520
2784
|
const doc = {
|
|
2521
2785
|
_id: sendId,
|
|
@@ -2554,7 +2818,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
2554
2818
|
};
|
|
2555
2819
|
return { doc, delayMs };
|
|
2556
2820
|
}
|
|
2557
|
-
function
|
|
2821
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
2822
|
+
const DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2558
2823
|
try {
|
|
2559
2824
|
const scheduled = new Date(scheduledMs);
|
|
2560
2825
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -2567,7 +2832,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
2567
2832
|
const utcMs = parse(utc);
|
|
2568
2833
|
const localMs = parse(local);
|
|
2569
2834
|
const offsetMs = utcMs - localMs;
|
|
2570
|
-
return offsetMs;
|
|
2835
|
+
return (offsetMs % DAY_MS2 + DAY_MS2) % DAY_MS2;
|
|
2571
2836
|
} catch {
|
|
2572
2837
|
return 0;
|
|
2573
2838
|
}
|
|
@@ -3514,6 +3779,7 @@ function suggestPolicyProgression(input) {
|
|
|
3514
3779
|
|
|
3515
3780
|
// src/server/runner/tick.ts
|
|
3516
3781
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3782
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3517
3783
|
async function runTick(ctx) {
|
|
3518
3784
|
try {
|
|
3519
3785
|
await ctx.collections.health.updateOne(
|
|
@@ -3554,12 +3820,18 @@ async function runTick(ctx) {
|
|
|
3554
3820
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3555
3821
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3556
3822
|
});
|
|
3823
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
3824
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
3825
|
+
});
|
|
3557
3826
|
await evaluateHealth(ctx).catch((err) => {
|
|
3558
3827
|
console.error("mailery: health evaluation failed", err);
|
|
3559
3828
|
});
|
|
3560
3829
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
3561
3830
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
3562
3831
|
});
|
|
3832
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
3833
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
3834
|
+
});
|
|
3563
3835
|
await Promise.all([
|
|
3564
3836
|
runDnsblChecks(ctx).catch((err) => {
|
|
3565
3837
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -3630,140 +3902,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
3630
3902
|
await processScheduledBroadcasts(ctx);
|
|
3631
3903
|
}
|
|
3632
3904
|
|
|
3633
|
-
// src/server/runner/webhook.ts
|
|
3634
|
-
function dimsFromSend(send) {
|
|
3635
|
-
if (!send) return null;
|
|
3636
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
3637
|
-
}
|
|
3638
|
-
async function applyWebhookEvent(event, ctx) {
|
|
3639
|
-
const send = await ctx.collections.sends.findOne(
|
|
3640
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
3641
|
-
{ sort: { queuedAt: -1 } }
|
|
3642
|
-
);
|
|
3643
|
-
switch (event.type) {
|
|
3644
|
-
case "delivered":
|
|
3645
|
-
if (send) {
|
|
3646
|
-
await ctx.collections.sends.updateOne(
|
|
3647
|
-
{ _id: send._id },
|
|
3648
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
3649
|
-
);
|
|
3650
|
-
}
|
|
3651
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
3652
|
-
break;
|
|
3653
|
-
case "open":
|
|
3654
|
-
if (send) {
|
|
3655
|
-
await ctx.collections.sends.updateOne(
|
|
3656
|
-
{ _id: send._id },
|
|
3657
|
-
{
|
|
3658
|
-
$set: {
|
|
3659
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
3660
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
3661
|
-
},
|
|
3662
|
-
$inc: { openCount: 1 }
|
|
3663
|
-
}
|
|
3664
|
-
);
|
|
3665
|
-
}
|
|
3666
|
-
break;
|
|
3667
|
-
case "click":
|
|
3668
|
-
if (send) {
|
|
3669
|
-
await ctx.collections.sends.updateOne(
|
|
3670
|
-
{ _id: send._id },
|
|
3671
|
-
{
|
|
3672
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
3673
|
-
$inc: { clickCount: 1 },
|
|
3674
|
-
$push: {
|
|
3675
|
-
clickedLinks: {
|
|
3676
|
-
url: event.details.clickedUrl ?? "",
|
|
3677
|
-
linkId: "",
|
|
3678
|
-
clickedAt: event.occurredAt
|
|
3679
|
-
}
|
|
3680
|
-
}
|
|
3681
|
-
}
|
|
3682
|
-
);
|
|
3683
|
-
}
|
|
3684
|
-
break;
|
|
3685
|
-
case "bounce": {
|
|
3686
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
3687
|
-
if (send) {
|
|
3688
|
-
await ctx.collections.sends.updateOne(
|
|
3689
|
-
{ _id: send._id },
|
|
3690
|
-
{
|
|
3691
|
-
$set: {
|
|
3692
|
-
status: "bounced",
|
|
3693
|
-
bounceType,
|
|
3694
|
-
bounceReason: event.details.bounceReason ?? null
|
|
3695
|
-
}
|
|
3696
|
-
}
|
|
3697
|
-
);
|
|
3698
|
-
}
|
|
3699
|
-
if (bounceType === "hard") {
|
|
3700
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
3701
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3702
|
-
{ emailAtSubscribe: event.email },
|
|
3703
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3704
|
-
);
|
|
3705
|
-
}
|
|
3706
|
-
{
|
|
3707
|
-
const dims = dimsFromSend(send);
|
|
3708
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
3709
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
3710
|
-
}
|
|
3711
|
-
break;
|
|
3712
|
-
}
|
|
3713
|
-
case "complaint":
|
|
3714
|
-
case "spam_report":
|
|
3715
|
-
if (send) {
|
|
3716
|
-
await ctx.collections.sends.updateOne(
|
|
3717
|
-
{ _id: send._id },
|
|
3718
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
3719
|
-
);
|
|
3720
|
-
}
|
|
3721
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
3722
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3723
|
-
{ emailAtSubscribe: event.email },
|
|
3724
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
3725
|
-
);
|
|
3726
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
3727
|
-
break;
|
|
3728
|
-
case "unsubscribe":
|
|
3729
|
-
if (send) {
|
|
3730
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
3731
|
-
}
|
|
3732
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
3733
|
-
await ctx.collections.subscriptions.updateOne(
|
|
3734
|
-
{ emailAtSubscribe: event.email },
|
|
3735
|
-
{
|
|
3736
|
-
$set: {
|
|
3737
|
-
status: "unsubscribed",
|
|
3738
|
-
unsubscribedAt: event.occurredAt,
|
|
3739
|
-
unsubscribeReason: "user_request",
|
|
3740
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
3741
|
-
}
|
|
3742
|
-
}
|
|
3743
|
-
);
|
|
3744
|
-
break;
|
|
3745
|
-
}
|
|
3746
|
-
}
|
|
3747
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
3748
|
-
const normalized = email.toLowerCase();
|
|
3749
|
-
await ctx.collections.suppressions.updateOne(
|
|
3750
|
-
{ email: normalized, scope },
|
|
3751
|
-
{
|
|
3752
|
-
$setOnInsert: {
|
|
3753
|
-
email: normalized,
|
|
3754
|
-
emailHash: sha256Hex(normalized),
|
|
3755
|
-
scope,
|
|
3756
|
-
reason,
|
|
3757
|
-
source: "provider_webhook",
|
|
3758
|
-
notes: null,
|
|
3759
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
3760
|
-
expiresAt: null
|
|
3761
|
-
}
|
|
3762
|
-
},
|
|
3763
|
-
{ upsert: true }
|
|
3764
|
-
);
|
|
3765
|
-
}
|
|
3766
|
-
|
|
3767
3905
|
// src/server/mailer.ts
|
|
3768
3906
|
var Mailer = class _Mailer {
|
|
3769
3907
|
db;
|
|
@@ -4228,41 +4366,48 @@ var Mailer = class _Mailer {
|
|
|
4228
4366
|
if (existing) return { sendId: String(existing._id) };
|
|
4229
4367
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
4230
4368
|
const sendId = new mongodb.ObjectId();
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4369
|
+
try {
|
|
4370
|
+
await this.collections.sends.insertOne({
|
|
4371
|
+
_id: sendId,
|
|
4372
|
+
dedupeKey,
|
|
4373
|
+
externalId: parsed.externalId,
|
|
4374
|
+
emailAtSend: contact.email,
|
|
4375
|
+
templateId: template._id,
|
|
4376
|
+
templateSlug: template.slug,
|
|
4377
|
+
flowRunId: null,
|
|
4378
|
+
broadcastId: null,
|
|
4379
|
+
manualSendBy: "sendOneOff",
|
|
4380
|
+
kind: template.kind,
|
|
4381
|
+
provider: providerName,
|
|
4382
|
+
providerMessageId: null,
|
|
4383
|
+
fromName: template.fromName,
|
|
4384
|
+
fromEmail: template.fromEmail,
|
|
4385
|
+
subject: template.subject,
|
|
4386
|
+
bodyHash: "",
|
|
4387
|
+
status: "queued",
|
|
4388
|
+
errorMessage: null,
|
|
4389
|
+
bounceType: null,
|
|
4390
|
+
bounceReason: null,
|
|
4391
|
+
links: [],
|
|
4392
|
+
vars: parsed.vars ?? {},
|
|
4393
|
+
openedAt: null,
|
|
4394
|
+
openCount: 0,
|
|
4395
|
+
firstClickAt: null,
|
|
4396
|
+
clickCount: 0,
|
|
4397
|
+
clickedLinks: [],
|
|
4398
|
+
unsubscribedAt: null,
|
|
4399
|
+
complainedAt: null,
|
|
4400
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
4401
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
4402
|
+
sentAt: null,
|
|
4403
|
+
deliveredAt: null
|
|
4404
|
+
});
|
|
4405
|
+
} catch (err) {
|
|
4406
|
+
if (err?.code !== 11e3) throw err;
|
|
4407
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
4408
|
+
if (winner) return { sendId: String(winner._id) };
|
|
4409
|
+
throw err;
|
|
4410
|
+
}
|
|
4266
4411
|
await this.queues.send.add(
|
|
4267
4412
|
"send",
|
|
4268
4413
|
{ sendId: String(sendId) },
|
|
@@ -4306,7 +4451,12 @@ var Mailer = class _Mailer {
|
|
|
4306
4451
|
await runTick(this.runnerContext);
|
|
4307
4452
|
},
|
|
4308
4453
|
advance: async (data) => {
|
|
4309
|
-
if (
|
|
4454
|
+
if (data.broadcastId) {
|
|
4455
|
+
if (!mongodb.ObjectId.isValid(data.broadcastId)) return;
|
|
4456
|
+
await dispatchBroadcastById(new mongodb.ObjectId(data.broadcastId), this.runnerContext);
|
|
4457
|
+
return;
|
|
4458
|
+
}
|
|
4459
|
+
if (!data.flowRunId || !mongodb.ObjectId.isValid(data.flowRunId)) return;
|
|
4310
4460
|
await processOneRunStep(new mongodb.ObjectId(data.flowRunId), this.runnerContext);
|
|
4311
4461
|
},
|
|
4312
4462
|
send: async (data) => {
|
|
@@ -4322,27 +4472,7 @@ var Mailer = class _Mailer {
|
|
|
4322
4472
|
}
|
|
4323
4473
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
4324
4474
|
async processWebhookBacklog() {
|
|
4325
|
-
|
|
4326
|
-
for (const evt of batch) {
|
|
4327
|
-
try {
|
|
4328
|
-
const normalized = evt.raw?.normalized;
|
|
4329
|
-
const details = normalized?.details ?? {};
|
|
4330
|
-
await applyWebhookEvent(
|
|
4331
|
-
{
|
|
4332
|
-
type: evt.normalizedType,
|
|
4333
|
-
providerEventId: evt.providerEventId,
|
|
4334
|
-
providerMessageId: evt.providerMessageId,
|
|
4335
|
-
email: evt.email,
|
|
4336
|
-
occurredAt: evt.occurredAt,
|
|
4337
|
-
details
|
|
4338
|
-
},
|
|
4339
|
-
this.runnerContext
|
|
4340
|
-
);
|
|
4341
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
4342
|
-
} catch (err) {
|
|
4343
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
4344
|
-
}
|
|
4345
|
-
}
|
|
4475
|
+
await processWebhookBacklog(this.runnerContext);
|
|
4346
4476
|
}
|
|
4347
4477
|
async stop() {
|
|
4348
4478
|
await this.queueDriver.close();
|
|
@@ -5071,7 +5201,7 @@ async function checkPostalAddress(mailer) {
|
|
|
5071
5201
|
label: "CAN-SPAM postal address",
|
|
5072
5202
|
severity: "warn",
|
|
5073
5203
|
message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
|
|
5074
|
-
hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference
|
|
5204
|
+
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."
|
|
5075
5205
|
};
|
|
5076
5206
|
}
|
|
5077
5207
|
async function checkDoiTemplate(mailer) {
|
|
@@ -5365,19 +5495,30 @@ async function persistScore(ctx, input) {
|
|
|
5365
5495
|
}
|
|
5366
5496
|
async function evaluateMailTesterGate(ctx, input) {
|
|
5367
5497
|
const cfg = ctx.config.mailTester;
|
|
5368
|
-
if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
|
|
5498
|
+
if (!cfg?.apiKey) return { allowed: true, reason: null, score: null, code: null };
|
|
5369
5499
|
const minScore = cfg.minScore ?? 8;
|
|
5370
5500
|
const key = mailTesterContentKey(input);
|
|
5371
5501
|
const cached = await findCachedScore(ctx, key);
|
|
5372
|
-
if (!cached)
|
|
5502
|
+
if (!cached) {
|
|
5503
|
+
if (cfg.requireScore) {
|
|
5504
|
+
return {
|
|
5505
|
+
allowed: false,
|
|
5506
|
+
reason: "No Mail-Tester score for this exact content. Run a deliverability check before publishing.",
|
|
5507
|
+
score: null,
|
|
5508
|
+
code: "no_score"
|
|
5509
|
+
};
|
|
5510
|
+
}
|
|
5511
|
+
return { allowed: true, reason: null, score: null, code: null };
|
|
5512
|
+
}
|
|
5373
5513
|
if (cached.score < minScore) {
|
|
5374
5514
|
return {
|
|
5375
5515
|
allowed: false,
|
|
5376
5516
|
reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
|
|
5377
|
-
score: cached
|
|
5517
|
+
score: cached,
|
|
5518
|
+
code: "low_score"
|
|
5378
5519
|
};
|
|
5379
5520
|
}
|
|
5380
|
-
return { allowed: true, reason: null, score: cached };
|
|
5521
|
+
return { allowed: true, reason: null, score: cached, code: null };
|
|
5381
5522
|
}
|
|
5382
5523
|
|
|
5383
5524
|
// src/server/api/admin.ts
|
|
@@ -6371,6 +6512,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6371
6512
|
res.json({
|
|
6372
6513
|
configured,
|
|
6373
6514
|
minScore: cfg?.minScore ?? 8,
|
|
6515
|
+
requireScore: !!cfg?.requireScore,
|
|
6374
6516
|
cacheHours: cfg?.cacheHours ?? 24,
|
|
6375
6517
|
score: cached
|
|
6376
6518
|
});
|
|
@@ -6585,9 +6727,10 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6585
6727
|
if (!gate.allowed) {
|
|
6586
6728
|
return res.status(422).json({
|
|
6587
6729
|
error: "mail_tester_blocked",
|
|
6730
|
+
code: gate.code,
|
|
6588
6731
|
message: gate.reason,
|
|
6589
6732
|
score: gate.score,
|
|
6590
|
-
hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
|
|
6733
|
+
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."
|
|
6591
6734
|
});
|
|
6592
6735
|
}
|
|
6593
6736
|
}
|
|
@@ -7395,7 +7538,7 @@ var DEDUPE_POLICIES = [
|
|
|
7395
7538
|
];
|
|
7396
7539
|
|
|
7397
7540
|
// src/server/index.ts
|
|
7398
|
-
var VERSION = "0.
|
|
7541
|
+
var VERSION = "0.8.1";
|
|
7399
7542
|
|
|
7400
7543
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
7401
7544
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|