mailery 0.8.0 → 0.9.0

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