mailery 0.8.0 → 0.9.0

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