mailery 0.8.0 → 0.8.1

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