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