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 +788 -659
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +788 -659
- package/dist/index.js.map +1 -1
- package/dist/{null-BCxlHRJ0.d.cts → null-CwXmiVLe.d.cts} +6 -0
- package/dist/{null-BCxlHRJ0.d.ts → null-CwXmiVLe.d.ts} +6 -0
- package/dist/testing.cjs +685 -556
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +685 -556
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
package/dist/testing.cjs
CHANGED
|
@@ -14447,6 +14447,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14447
14447
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
14448
14448
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
14449
14449
|
{ key: { name: 1, occurredAt: -1 } },
|
|
14450
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
14450
14451
|
{ key: { externalId: 1, name: 1 } }
|
|
14451
14452
|
]),
|
|
14452
14453
|
c.flows.createIndexes([
|
|
@@ -14457,7 +14458,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14457
14458
|
c.flowRuns.createIndexes([
|
|
14458
14459
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
14459
14460
|
{ key: { externalId: 1, flowId: 1 } },
|
|
14460
|
-
{ key: { flowId: 1, status: 1 } }
|
|
14461
|
+
{ key: { flowId: 1, status: 1 } },
|
|
14462
|
+
{
|
|
14463
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
14464
|
+
unique: true,
|
|
14465
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
14466
|
+
}
|
|
14461
14467
|
]),
|
|
14462
14468
|
c.templates.createIndexes([
|
|
14463
14469
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -14645,7 +14651,13 @@ var BullDriver = class _BullDriver {
|
|
|
14645
14651
|
constructor(bull, redis) {
|
|
14646
14652
|
this.bull = bull;
|
|
14647
14653
|
this.redis = redis;
|
|
14648
|
-
const opts = {
|
|
14654
|
+
const opts = {
|
|
14655
|
+
connection: redis,
|
|
14656
|
+
defaultJobOptions: {
|
|
14657
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
14658
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
14659
|
+
}
|
|
14660
|
+
};
|
|
14649
14661
|
this.bullQueues = {
|
|
14650
14662
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
14651
14663
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -14663,7 +14675,11 @@ var BullDriver = class _BullDriver {
|
|
|
14663
14675
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
14664
14676
|
"mailer-tick-repeat",
|
|
14665
14677
|
{ every: intervalSeconds * 1e3 },
|
|
14666
|
-
{
|
|
14678
|
+
{
|
|
14679
|
+
name: "tick",
|
|
14680
|
+
data: {},
|
|
14681
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
14682
|
+
}
|
|
14667
14683
|
);
|
|
14668
14684
|
}
|
|
14669
14685
|
async startWorkers(opts) {
|
|
@@ -14773,9 +14789,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14773
14789
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
14774
14790
|
);
|
|
14775
14791
|
}
|
|
14792
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
14776
14793
|
const backend = new backendMod.MongoBackend({
|
|
14777
14794
|
mongo: opts.db,
|
|
14778
|
-
collection:
|
|
14795
|
+
collection: collectionName
|
|
14779
14796
|
});
|
|
14780
14797
|
const agenda = new agendaMod.Agenda({
|
|
14781
14798
|
backend,
|
|
@@ -14784,13 +14801,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14784
14801
|
maxConcurrency: 50,
|
|
14785
14802
|
defaultConcurrency: 5
|
|
14786
14803
|
});
|
|
14787
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
14804
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
14788
14805
|
}
|
|
14789
14806
|
db;
|
|
14790
|
-
|
|
14807
|
+
collName;
|
|
14808
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
14791
14809
|
this.agenda = agenda;
|
|
14792
14810
|
this.agendaMod = agendaMod;
|
|
14793
14811
|
this.db = db;
|
|
14812
|
+
this.collName = collectionName;
|
|
14794
14813
|
this.queues = {
|
|
14795
14814
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
14796
14815
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -14823,10 +14842,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14823
14842
|
}
|
|
14824
14843
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
14825
14844
|
jobsCollection() {
|
|
14826
|
-
return this.db.collection(this.
|
|
14827
|
-
}
|
|
14828
|
-
collectionName() {
|
|
14829
|
-
return "_mailerJobs";
|
|
14845
|
+
return this.db.collection(this.collName);
|
|
14830
14846
|
}
|
|
14831
14847
|
async findPending(name, jobId) {
|
|
14832
14848
|
return this.jobsCollection().findOne({
|
|
@@ -14836,12 +14852,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14836
14852
|
});
|
|
14837
14853
|
}
|
|
14838
14854
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
14839
|
-
if (!this.started) {
|
|
14840
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
14841
|
-
}, { concurrency: 1 });
|
|
14842
|
-
await this.agenda.start();
|
|
14843
|
-
this.started = true;
|
|
14844
|
-
}
|
|
14845
14855
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
14846
14856
|
}
|
|
14847
14857
|
async startWorkers(opts) {
|
|
@@ -14944,6 +14954,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
14944
14954
|
|
|
14945
14955
|
// src/server/runner/triggers.ts
|
|
14946
14956
|
var BATCH_SIZE = 1e3;
|
|
14957
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
14947
14958
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
14948
14959
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
14949
14960
|
for (const flow of flows) {
|
|
@@ -14954,16 +14965,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
14954
14965
|
const eventName = flow.trigger.eventName;
|
|
14955
14966
|
if (!eventName) return;
|
|
14956
14967
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
14957
|
-
const
|
|
14968
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
14969
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
14958
14970
|
if (events.length === 0) return;
|
|
14959
14971
|
for (const event of events) {
|
|
14960
14972
|
await tryEnterFlow(flow, event, ctx);
|
|
14961
14973
|
}
|
|
14962
|
-
const
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
14974
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
14975
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
14976
|
+
await ctx.collections.flows.updateOne(
|
|
14977
|
+
{ _id: flow._id },
|
|
14978
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
14979
|
+
);
|
|
14980
|
+
}
|
|
14967
14981
|
}
|
|
14968
14982
|
async function tryEnterFlow(flow, event, ctx) {
|
|
14969
14983
|
if (flow.trigger.once) {
|
|
@@ -14975,120 +14989,456 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14975
14989
|
}
|
|
14976
14990
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
14977
14991
|
if (!sub || sub.status !== "subscribed") return;
|
|
14978
|
-
|
|
14979
|
-
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14984
|
-
|
|
14985
|
-
|
|
14986
|
-
|
|
14987
|
-
|
|
14988
|
-
|
|
14989
|
-
|
|
14990
|
-
|
|
14991
|
-
|
|
14992
|
-
|
|
14993
|
-
|
|
14994
|
-
|
|
14995
|
-
|
|
14996
|
-
|
|
14992
|
+
let result;
|
|
14993
|
+
try {
|
|
14994
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
14995
|
+
externalId: event.externalId,
|
|
14996
|
+
flowId: flow._id,
|
|
14997
|
+
flowSlug: flow.slug,
|
|
14998
|
+
flowVersion: flow.version,
|
|
14999
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
15000
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
15001
|
+
triggerDedupeKey: event.dedupeKey,
|
|
15002
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
15003
|
+
status: "active",
|
|
15004
|
+
currentStepIndex: 0,
|
|
15005
|
+
currentBranchPath: [],
|
|
15006
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
15007
|
+
attemptsForCurrentStep: 0,
|
|
15008
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
15009
|
+
exitedAt: null,
|
|
15010
|
+
exitReason: null,
|
|
15011
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
15012
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15013
|
+
});
|
|
15014
|
+
} catch (err) {
|
|
15015
|
+
if (err?.code !== 11e3) throw err;
|
|
15016
|
+
return;
|
|
15017
|
+
}
|
|
14997
15018
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
14998
15019
|
}
|
|
14999
15020
|
|
|
15000
|
-
// src/server/
|
|
15001
|
-
|
|
15002
|
-
|
|
15003
|
-
const
|
|
15004
|
-
if (
|
|
15005
|
-
|
|
15006
|
-
|
|
15007
|
-
|
|
15008
|
-
|
|
15009
|
-
|
|
15010
|
-
|
|
15011
|
-
|
|
15012
|
-
|
|
15013
|
-
|
|
15014
|
-
|
|
15015
|
-
|
|
15016
|
-
|
|
15017
|
-
|
|
15018
|
-
|
|
15019
|
-
|
|
15020
|
-
|
|
15021
|
-
|
|
15022
|
-
|
|
15023
|
-
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
|
|
15028
|
-
if (
|
|
15029
|
-
|
|
15030
|
-
|
|
15031
|
-
|
|
15032
|
-
|
|
15033
|
-
}
|
|
15034
|
-
if ("any" in p) {
|
|
15035
|
-
for (const sub of p.any) {
|
|
15036
|
-
if (await evaluatePredicate(sub, ctx)) return true;
|
|
15021
|
+
// src/server/templates/sender-domain.ts
|
|
15022
|
+
function extractDomain(email) {
|
|
15023
|
+
if (typeof email !== "string") return null;
|
|
15024
|
+
const at = email.lastIndexOf("@");
|
|
15025
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15026
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15027
|
+
}
|
|
15028
|
+
|
|
15029
|
+
// src/server/runner/health.ts
|
|
15030
|
+
var ZERO_COUNTERS = {
|
|
15031
|
+
sent: 0,
|
|
15032
|
+
delivered: 0,
|
|
15033
|
+
bounced: 0,
|
|
15034
|
+
hardBounced: 0,
|
|
15035
|
+
softBounced: 0,
|
|
15036
|
+
complained: 0,
|
|
15037
|
+
failedToSend: 0
|
|
15038
|
+
};
|
|
15039
|
+
var ZERO_RATES = {
|
|
15040
|
+
bounceRate: 0,
|
|
15041
|
+
hardBounceRate: 0,
|
|
15042
|
+
complaintRate: 0,
|
|
15043
|
+
failureRate: 0
|
|
15044
|
+
};
|
|
15045
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15046
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15047
|
+
const writes = [];
|
|
15048
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15049
|
+
if (dims) {
|
|
15050
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15051
|
+
if (domain) {
|
|
15052
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15053
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15037
15054
|
}
|
|
15038
|
-
return false;
|
|
15039
|
-
}
|
|
15040
|
-
if ("not" in p) {
|
|
15041
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
15042
15055
|
}
|
|
15043
|
-
|
|
15056
|
+
await Promise.all(writes);
|
|
15044
15057
|
}
|
|
15045
|
-
async function
|
|
15046
|
-
|
|
15047
|
-
|
|
15048
|
-
|
|
15049
|
-
|
|
15050
|
-
|
|
15058
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15059
|
+
await ctx.collections.health.updateOne(
|
|
15060
|
+
{ _id },
|
|
15061
|
+
{
|
|
15062
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
15063
|
+
$setOnInsert: {
|
|
15064
|
+
_id,
|
|
15065
|
+
senderDomain,
|
|
15066
|
+
kind,
|
|
15067
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15068
|
+
windowDurationMs: windowMs,
|
|
15069
|
+
status: "healthy",
|
|
15070
|
+
trippedAt: null,
|
|
15071
|
+
trippedReason: null,
|
|
15072
|
+
manuallyResumedAt: null,
|
|
15073
|
+
rates: { ...ZERO_RATES }
|
|
15074
|
+
},
|
|
15075
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15076
|
+
},
|
|
15077
|
+
{ upsert: true }
|
|
15078
|
+
);
|
|
15051
15079
|
}
|
|
15052
|
-
async function
|
|
15053
|
-
const
|
|
15054
|
-
|
|
15055
|
-
|
|
15056
|
-
if (
|
|
15057
|
-
const
|
|
15058
|
-
if (
|
|
15059
|
-
|
|
15060
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
15080
|
+
async function evaluateHealth(ctx) {
|
|
15081
|
+
const cb = ctx.config.circuitBreaker;
|
|
15082
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15083
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15084
|
+
if (docs.length === 0) return;
|
|
15085
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15086
|
+
if (!hasAgg) {
|
|
15087
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15061
15088
|
}
|
|
15062
|
-
const
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
if (
|
|
15066
|
-
|
|
15089
|
+
for (const doc of docs) {
|
|
15090
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15091
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15092
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15093
|
+
await ctx.collections.health.updateOne(
|
|
15094
|
+
{ _id: doc._id },
|
|
15095
|
+
{
|
|
15096
|
+
$set: {
|
|
15097
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15098
|
+
windowDurationMs: windowMs,
|
|
15099
|
+
counters: { ...ZERO_COUNTERS },
|
|
15100
|
+
rates: { ...ZERO_RATES },
|
|
15101
|
+
status: "healthy",
|
|
15102
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15103
|
+
}
|
|
15104
|
+
}
|
|
15105
|
+
);
|
|
15067
15106
|
continue;
|
|
15068
15107
|
}
|
|
15069
|
-
const
|
|
15070
|
-
|
|
15108
|
+
const c = doc.counters;
|
|
15109
|
+
const total = c.sent || 1;
|
|
15110
|
+
const rates = {
|
|
15111
|
+
bounceRate: c.bounced / total,
|
|
15112
|
+
hardBounceRate: c.hardBounced / total,
|
|
15113
|
+
complaintRate: c.complained / total,
|
|
15114
|
+
failureRate: c.failedToSend / total
|
|
15115
|
+
};
|
|
15116
|
+
await ctx.collections.health.updateOne(
|
|
15117
|
+
{ _id: doc._id },
|
|
15118
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15119
|
+
);
|
|
15120
|
+
if (isAgg) continue;
|
|
15121
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15122
|
+
if (doc.status === "tripped") continue;
|
|
15123
|
+
let trippedReason = null;
|
|
15124
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15125
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15126
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15127
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15128
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15129
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15130
|
+
}
|
|
15131
|
+
if (trippedReason) {
|
|
15132
|
+
const result = await ctx.collections.health.updateOne(
|
|
15133
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15134
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15135
|
+
);
|
|
15136
|
+
if (result.modifiedCount > 0) {
|
|
15137
|
+
if (ctx.audit) {
|
|
15138
|
+
try {
|
|
15139
|
+
await ctx.audit({
|
|
15140
|
+
actor: "system:circuit-breaker",
|
|
15141
|
+
action: "health.trip",
|
|
15142
|
+
resource: {
|
|
15143
|
+
collection: "mailer_health",
|
|
15144
|
+
id: String(doc._id),
|
|
15145
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15146
|
+
},
|
|
15147
|
+
diffSummary: trippedReason
|
|
15148
|
+
});
|
|
15149
|
+
} catch {
|
|
15150
|
+
}
|
|
15151
|
+
}
|
|
15152
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15153
|
+
try {
|
|
15154
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15155
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15156
|
+
rates
|
|
15157
|
+
});
|
|
15158
|
+
} catch {
|
|
15159
|
+
}
|
|
15160
|
+
}
|
|
15161
|
+
}
|
|
15162
|
+
continue;
|
|
15163
|
+
}
|
|
15164
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15165
|
+
if (doc.status !== "degraded") {
|
|
15166
|
+
await ctx.collections.health.updateOne(
|
|
15167
|
+
{ _id: doc._id },
|
|
15168
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15169
|
+
);
|
|
15170
|
+
}
|
|
15171
|
+
} else if (doc.status === "degraded") {
|
|
15172
|
+
await ctx.collections.health.updateOne(
|
|
15173
|
+
{ _id: doc._id },
|
|
15174
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15175
|
+
);
|
|
15176
|
+
}
|
|
15071
15177
|
}
|
|
15072
|
-
return n;
|
|
15073
15178
|
}
|
|
15074
|
-
function
|
|
15075
|
-
const
|
|
15076
|
-
|
|
15077
|
-
|
|
15078
|
-
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15079
|
-
}
|
|
15080
|
-
return null;
|
|
15179
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15180
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15181
|
+
const id = healthBucketId(domain, kind);
|
|
15182
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15081
15183
|
}
|
|
15082
15184
|
|
|
15083
|
-
// src/server/runner/
|
|
15084
|
-
|
|
15085
|
-
|
|
15086
|
-
|
|
15087
|
-
|
|
15088
|
-
|
|
15089
|
-
|
|
15090
|
-
|
|
15091
|
-
|
|
15185
|
+
// src/server/runner/webhook.ts
|
|
15186
|
+
function dimsFromSend(send) {
|
|
15187
|
+
if (!send) return null;
|
|
15188
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
15189
|
+
}
|
|
15190
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
15191
|
+
const filter = { processed: false };
|
|
15192
|
+
if (opts.olderThanMs) {
|
|
15193
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
15194
|
+
}
|
|
15195
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
15196
|
+
for (const evt of batch) {
|
|
15197
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
15198
|
+
{ _id: evt._id, processed: false },
|
|
15199
|
+
{ $set: { processed: true } }
|
|
15200
|
+
);
|
|
15201
|
+
if (!claimed) continue;
|
|
15202
|
+
try {
|
|
15203
|
+
const normalized = evt.raw?.normalized;
|
|
15204
|
+
const details = normalized?.details ?? {};
|
|
15205
|
+
await applyWebhookEvent(
|
|
15206
|
+
{
|
|
15207
|
+
type: evt.normalizedType,
|
|
15208
|
+
providerEventId: evt.providerEventId,
|
|
15209
|
+
providerMessageId: evt.providerMessageId,
|
|
15210
|
+
email: evt.email,
|
|
15211
|
+
occurredAt: evt.occurredAt,
|
|
15212
|
+
details
|
|
15213
|
+
},
|
|
15214
|
+
ctx
|
|
15215
|
+
);
|
|
15216
|
+
} catch (err) {
|
|
15217
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
15218
|
+
}
|
|
15219
|
+
}
|
|
15220
|
+
}
|
|
15221
|
+
async function applyWebhookEvent(event, ctx) {
|
|
15222
|
+
const send = await ctx.collections.sends.findOne(
|
|
15223
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
15224
|
+
{ sort: { queuedAt: -1 } }
|
|
15225
|
+
);
|
|
15226
|
+
switch (event.type) {
|
|
15227
|
+
case "delivered":
|
|
15228
|
+
if (send) {
|
|
15229
|
+
await ctx.collections.sends.updateOne(
|
|
15230
|
+
{ _id: send._id },
|
|
15231
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
15232
|
+
);
|
|
15233
|
+
}
|
|
15234
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
15235
|
+
break;
|
|
15236
|
+
case "open":
|
|
15237
|
+
if (send) {
|
|
15238
|
+
await ctx.collections.sends.updateOne(
|
|
15239
|
+
{ _id: send._id },
|
|
15240
|
+
{
|
|
15241
|
+
$set: {
|
|
15242
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
15243
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
15244
|
+
},
|
|
15245
|
+
$inc: { openCount: 1 }
|
|
15246
|
+
}
|
|
15247
|
+
);
|
|
15248
|
+
}
|
|
15249
|
+
break;
|
|
15250
|
+
case "click":
|
|
15251
|
+
if (send) {
|
|
15252
|
+
await ctx.collections.sends.updateOne(
|
|
15253
|
+
{ _id: send._id },
|
|
15254
|
+
{
|
|
15255
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
15256
|
+
$inc: { clickCount: 1 },
|
|
15257
|
+
$push: {
|
|
15258
|
+
clickedLinks: {
|
|
15259
|
+
url: event.details.clickedUrl ?? "",
|
|
15260
|
+
linkId: "",
|
|
15261
|
+
clickedAt: event.occurredAt
|
|
15262
|
+
}
|
|
15263
|
+
}
|
|
15264
|
+
}
|
|
15265
|
+
);
|
|
15266
|
+
}
|
|
15267
|
+
break;
|
|
15268
|
+
case "bounce": {
|
|
15269
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
15270
|
+
if (send) {
|
|
15271
|
+
await ctx.collections.sends.updateOne(
|
|
15272
|
+
{ _id: send._id },
|
|
15273
|
+
{
|
|
15274
|
+
$set: {
|
|
15275
|
+
status: "bounced",
|
|
15276
|
+
bounceType,
|
|
15277
|
+
bounceReason: event.details.bounceReason ?? null
|
|
15278
|
+
}
|
|
15279
|
+
}
|
|
15280
|
+
);
|
|
15281
|
+
}
|
|
15282
|
+
if (bounceType === "hard") {
|
|
15283
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
15284
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15285
|
+
{ emailAtSubscribe: event.email },
|
|
15286
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15287
|
+
);
|
|
15288
|
+
}
|
|
15289
|
+
{
|
|
15290
|
+
const dims = dimsFromSend(send);
|
|
15291
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
15292
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
15293
|
+
}
|
|
15294
|
+
break;
|
|
15295
|
+
}
|
|
15296
|
+
case "complaint":
|
|
15297
|
+
case "spam_report":
|
|
15298
|
+
if (send) {
|
|
15299
|
+
await ctx.collections.sends.updateOne(
|
|
15300
|
+
{ _id: send._id },
|
|
15301
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
15302
|
+
);
|
|
15303
|
+
}
|
|
15304
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
15305
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15306
|
+
{ emailAtSubscribe: event.email },
|
|
15307
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15308
|
+
);
|
|
15309
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
15310
|
+
break;
|
|
15311
|
+
case "unsubscribe":
|
|
15312
|
+
if (send) {
|
|
15313
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
15314
|
+
}
|
|
15315
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
15316
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15317
|
+
{ emailAtSubscribe: event.email },
|
|
15318
|
+
{
|
|
15319
|
+
$set: {
|
|
15320
|
+
status: "unsubscribed",
|
|
15321
|
+
unsubscribedAt: event.occurredAt,
|
|
15322
|
+
unsubscribeReason: "user_request",
|
|
15323
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15324
|
+
}
|
|
15325
|
+
}
|
|
15326
|
+
);
|
|
15327
|
+
break;
|
|
15328
|
+
}
|
|
15329
|
+
}
|
|
15330
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
15331
|
+
const normalized = email.toLowerCase();
|
|
15332
|
+
await ctx.collections.suppressions.updateOne(
|
|
15333
|
+
{ email: normalized, scope },
|
|
15334
|
+
{
|
|
15335
|
+
$setOnInsert: {
|
|
15336
|
+
email: normalized,
|
|
15337
|
+
emailHash: sha256Hex(normalized),
|
|
15338
|
+
scope,
|
|
15339
|
+
reason,
|
|
15340
|
+
source: "provider_webhook",
|
|
15341
|
+
notes: null,
|
|
15342
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
15343
|
+
expiresAt: null
|
|
15344
|
+
}
|
|
15345
|
+
},
|
|
15346
|
+
{ upsert: true }
|
|
15347
|
+
);
|
|
15348
|
+
}
|
|
15349
|
+
|
|
15350
|
+
// src/server/runner/predicate.ts
|
|
15351
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
15352
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
15353
|
+
const p = predicate;
|
|
15354
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
15355
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
15356
|
+
if ("fieldEquals" in p) {
|
|
15357
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
15358
|
+
}
|
|
15359
|
+
if ("fieldExists" in p) {
|
|
15360
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
15361
|
+
}
|
|
15362
|
+
if ("subscriptionStatus" in p) {
|
|
15363
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
15364
|
+
return sub?.status === p.subscriptionStatus;
|
|
15365
|
+
}
|
|
15366
|
+
if ("hasFiredEvent" in p) {
|
|
15367
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
15368
|
+
}
|
|
15369
|
+
if ("notHasFiredEvent" in p) {
|
|
15370
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
15371
|
+
}
|
|
15372
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
15373
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
15374
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
15375
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
15376
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
15377
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
15378
|
+
if ("all" in p) {
|
|
15379
|
+
for (const sub of p.all) {
|
|
15380
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
15381
|
+
}
|
|
15382
|
+
return true;
|
|
15383
|
+
}
|
|
15384
|
+
if ("any" in p) {
|
|
15385
|
+
for (const sub of p.any) {
|
|
15386
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
15387
|
+
}
|
|
15388
|
+
return false;
|
|
15389
|
+
}
|
|
15390
|
+
if ("not" in p) {
|
|
15391
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
15392
|
+
}
|
|
15393
|
+
return false;
|
|
15394
|
+
}
|
|
15395
|
+
async function hasEvent(ctx, name, opts) {
|
|
15396
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
15397
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15398
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
15399
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
15400
|
+
return !!found;
|
|
15401
|
+
}
|
|
15402
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
15403
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
15404
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
15405
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
15406
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
15407
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15408
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
15409
|
+
if (!excludeBots) {
|
|
15410
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
15411
|
+
}
|
|
15412
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
15413
|
+
let n = 0;
|
|
15414
|
+
for (const s of docs) {
|
|
15415
|
+
if (kind === "opened") {
|
|
15416
|
+
n++;
|
|
15417
|
+
continue;
|
|
15418
|
+
}
|
|
15419
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
15420
|
+
if (hasHumanClick) n++;
|
|
15421
|
+
}
|
|
15422
|
+
return n;
|
|
15423
|
+
}
|
|
15424
|
+
function effectiveLowerBound(ctx, opts) {
|
|
15425
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
15426
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
15427
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
15428
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15429
|
+
}
|
|
15430
|
+
return null;
|
|
15431
|
+
}
|
|
15432
|
+
|
|
15433
|
+
// src/server/runner/delivery-window.ts
|
|
15434
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15435
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15436
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15437
|
+
let candidate = now;
|
|
15438
|
+
if (window2.timeOfDay) {
|
|
15439
|
+
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15440
|
+
const local = localParts(candidate, tz);
|
|
15441
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
15092
15442
|
if (candidate.getTime() < todaySlot.getTime()) {
|
|
15093
15443
|
candidate = todaySlot;
|
|
15094
15444
|
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
@@ -15220,7 +15570,8 @@ function applyTracking(html, opts) {
|
|
|
15220
15570
|
const seen = /* @__PURE__ */ new Map();
|
|
15221
15571
|
let out = html;
|
|
15222
15572
|
if (opts.trackClicks) {
|
|
15223
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
15573
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
15574
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
15224
15575
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
15225
15576
|
let linkId = seen.get(url);
|
|
15226
15577
|
if (!linkId) {
|
|
@@ -15252,236 +15603,95 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
15252
15603
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
15253
15604
|
return false;
|
|
15254
15605
|
}
|
|
15255
|
-
|
|
15256
|
-
|
|
15257
|
-
|
|
15258
|
-
|
|
15259
|
-
|
|
15260
|
-
|
|
15261
|
-
|
|
15262
|
-
hb.registerHelper("gt", (a, b) => a > b);
|
|
15263
|
-
hb.registerHelper("lt", (a, b) => a < b);
|
|
15264
|
-
hb.registerHelper("gte", (a, b) => a >= b);
|
|
15265
|
-
hb.registerHelper("lte", (a, b) => a <= b);
|
|
15266
|
-
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
15267
|
-
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
15268
|
-
hb.registerHelper("not", (a) => !a);
|
|
15269
|
-
hb.registerHelper("formatDate", (value, fmt) => {
|
|
15270
|
-
if (!value) return "";
|
|
15271
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
15272
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
15273
|
-
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
15274
|
-
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
15275
|
-
return d.toISOString().slice(0, 10);
|
|
15276
|
-
});
|
|
15277
|
-
hb.registerHelper("formatNumber", (n) => {
|
|
15278
|
-
const v = Number(n);
|
|
15279
|
-
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
15280
|
-
});
|
|
15281
|
-
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
15282
|
-
const v = Number(cents);
|
|
15283
|
-
if (!Number.isFinite(v)) return "";
|
|
15284
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
15285
|
-
});
|
|
15286
|
-
hb.registerHelper(
|
|
15287
|
-
"pluralize",
|
|
15288
|
-
(n, one, many) => Number(n) === 1 ? one : many
|
|
15289
|
-
);
|
|
15290
|
-
if (extra) {
|
|
15291
|
-
for (const [name, fn] of Object.entries(extra)) {
|
|
15292
|
-
hb.registerHelper(name, fn);
|
|
15293
|
-
}
|
|
15294
|
-
}
|
|
15295
|
-
return hb;
|
|
15296
|
-
}
|
|
15297
|
-
|
|
15298
|
-
// src/server/runner/send.ts
|
|
15299
|
-
init_vars();
|
|
15300
|
-
|
|
15301
|
-
// src/server/runner/suppression.ts
|
|
15302
|
-
var SCOPES_BY_KIND = {
|
|
15303
|
-
marketing: ["all", "marketing"],
|
|
15304
|
-
transactional: ["all", "transactional"]
|
|
15606
|
+
var NAMED_ENTITIES = {
|
|
15607
|
+
amp: "&",
|
|
15608
|
+
lt: "<",
|
|
15609
|
+
gt: ">",
|
|
15610
|
+
quot: '"',
|
|
15611
|
+
apos: "'",
|
|
15612
|
+
nbsp: "\xA0"
|
|
15305
15613
|
};
|
|
15306
|
-
|
|
15307
|
-
|
|
15308
|
-
|
|
15309
|
-
|
|
15310
|
-
|
|
15311
|
-
|
|
15312
|
-
|
|
15313
|
-
|
|
15314
|
-
|
|
15315
|
-
const hashed = await collections.suppressions.findOne({
|
|
15316
|
-
emailHash: sha256Hex(normalized),
|
|
15317
|
-
scope: { $in: allowed }
|
|
15318
|
-
});
|
|
15319
|
-
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
15320
|
-
return { suppressed: false };
|
|
15321
|
-
}
|
|
15322
|
-
|
|
15323
|
-
// src/server/templates/sender-domain.ts
|
|
15324
|
-
function extractDomain(email) {
|
|
15325
|
-
if (typeof email !== "string") return null;
|
|
15326
|
-
const at = email.lastIndexOf("@");
|
|
15327
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
15328
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
15329
|
-
}
|
|
15330
|
-
|
|
15331
|
-
// src/server/runner/health.ts
|
|
15332
|
-
var ZERO_COUNTERS = {
|
|
15333
|
-
sent: 0,
|
|
15334
|
-
delivered: 0,
|
|
15335
|
-
bounced: 0,
|
|
15336
|
-
hardBounced: 0,
|
|
15337
|
-
softBounced: 0,
|
|
15338
|
-
complained: 0,
|
|
15339
|
-
failedToSend: 0
|
|
15340
|
-
};
|
|
15341
|
-
var ZERO_RATES = {
|
|
15342
|
-
bounceRate: 0,
|
|
15343
|
-
hardBounceRate: 0,
|
|
15344
|
-
complaintRate: 0,
|
|
15345
|
-
failureRate: 0
|
|
15346
|
-
};
|
|
15347
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15348
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15349
|
-
const writes = [];
|
|
15350
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15351
|
-
if (dims) {
|
|
15352
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15353
|
-
if (domain) {
|
|
15354
|
-
const id = healthBucketId(domain, dims.kind);
|
|
15355
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15356
|
-
}
|
|
15357
|
-
}
|
|
15358
|
-
await Promise.all(writes);
|
|
15359
|
-
}
|
|
15360
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15361
|
-
await ctx.collections.health.updateOne(
|
|
15362
|
-
{ _id },
|
|
15363
|
-
{
|
|
15364
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
15365
|
-
$setOnInsert: {
|
|
15366
|
-
_id,
|
|
15367
|
-
senderDomain,
|
|
15368
|
-
kind,
|
|
15369
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15370
|
-
windowDurationMs: windowMs,
|
|
15371
|
-
status: "healthy",
|
|
15372
|
-
trippedAt: null,
|
|
15373
|
-
trippedReason: null,
|
|
15374
|
-
manuallyResumedAt: null,
|
|
15375
|
-
rates: { ...ZERO_RATES }
|
|
15376
|
-
},
|
|
15377
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15378
|
-
},
|
|
15379
|
-
{ upsert: true }
|
|
15380
|
-
);
|
|
15381
|
-
}
|
|
15382
|
-
async function evaluateHealth(ctx) {
|
|
15383
|
-
const cb = ctx.config.circuitBreaker;
|
|
15384
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15385
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
15386
|
-
if (docs.length === 0) return;
|
|
15387
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15388
|
-
if (!hasAgg) {
|
|
15389
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15390
|
-
}
|
|
15391
|
-
for (const doc of docs) {
|
|
15392
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15393
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15394
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15395
|
-
await ctx.collections.health.updateOne(
|
|
15396
|
-
{ _id: doc._id },
|
|
15397
|
-
{
|
|
15398
|
-
$set: {
|
|
15399
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15400
|
-
windowDurationMs: windowMs,
|
|
15401
|
-
counters: { ...ZERO_COUNTERS },
|
|
15402
|
-
rates: { ...ZERO_RATES },
|
|
15403
|
-
status: "healthy",
|
|
15404
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
15405
|
-
}
|
|
15406
|
-
}
|
|
15407
|
-
);
|
|
15408
|
-
continue;
|
|
15409
|
-
}
|
|
15410
|
-
const c = doc.counters;
|
|
15411
|
-
const total = c.sent || 1;
|
|
15412
|
-
const rates = {
|
|
15413
|
-
bounceRate: c.bounced / total,
|
|
15414
|
-
hardBounceRate: c.hardBounced / total,
|
|
15415
|
-
complaintRate: c.complained / total,
|
|
15416
|
-
failureRate: c.failedToSend / total
|
|
15417
|
-
};
|
|
15418
|
-
await ctx.collections.health.updateOne(
|
|
15419
|
-
{ _id: doc._id },
|
|
15420
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15421
|
-
);
|
|
15422
|
-
if (isAgg) continue;
|
|
15423
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15424
|
-
if (doc.status === "tripped") continue;
|
|
15425
|
-
let trippedReason = null;
|
|
15426
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15427
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15428
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15429
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15430
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15431
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15432
|
-
}
|
|
15433
|
-
if (trippedReason) {
|
|
15434
|
-
const result = await ctx.collections.health.updateOne(
|
|
15435
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15436
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15437
|
-
);
|
|
15438
|
-
if (result.modifiedCount > 0) {
|
|
15439
|
-
if (ctx.audit) {
|
|
15440
|
-
try {
|
|
15441
|
-
await ctx.audit({
|
|
15442
|
-
actor: "system:circuit-breaker",
|
|
15443
|
-
action: "health.trip",
|
|
15444
|
-
resource: {
|
|
15445
|
-
collection: "mailer_health",
|
|
15446
|
-
id: String(doc._id),
|
|
15447
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15448
|
-
},
|
|
15449
|
-
diffSummary: trippedReason
|
|
15450
|
-
});
|
|
15451
|
-
} catch {
|
|
15452
|
-
}
|
|
15453
|
-
}
|
|
15454
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
15455
|
-
try {
|
|
15456
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
15457
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15458
|
-
rates
|
|
15459
|
-
});
|
|
15460
|
-
} catch {
|
|
15461
|
-
}
|
|
15462
|
-
}
|
|
15614
|
+
function decodeHtmlEntities(input) {
|
|
15615
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
15616
|
+
if (body[0] === "#") {
|
|
15617
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
15618
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
15619
|
+
try {
|
|
15620
|
+
return String.fromCodePoint(code);
|
|
15621
|
+
} catch {
|
|
15622
|
+
return match;
|
|
15463
15623
|
}
|
|
15464
|
-
continue;
|
|
15465
15624
|
}
|
|
15466
|
-
|
|
15467
|
-
|
|
15468
|
-
|
|
15469
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
15472
|
-
|
|
15473
|
-
|
|
15474
|
-
|
|
15475
|
-
|
|
15476
|
-
|
|
15477
|
-
|
|
15625
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
15626
|
+
return named ?? match;
|
|
15627
|
+
});
|
|
15628
|
+
}
|
|
15629
|
+
function shortHash(input) {
|
|
15630
|
+
return crypto2__default.default.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
15631
|
+
}
|
|
15632
|
+
function makeHandlebars(extra) {
|
|
15633
|
+
const hb = Handlebars__default.default.create();
|
|
15634
|
+
hb.registerHelper("eq", (a, b) => a === b);
|
|
15635
|
+
hb.registerHelper("ne", (a, b) => a !== b);
|
|
15636
|
+
hb.registerHelper("gt", (a, b) => a > b);
|
|
15637
|
+
hb.registerHelper("lt", (a, b) => a < b);
|
|
15638
|
+
hb.registerHelper("gte", (a, b) => a >= b);
|
|
15639
|
+
hb.registerHelper("lte", (a, b) => a <= b);
|
|
15640
|
+
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
15641
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
15642
|
+
hb.registerHelper("not", (a) => !a);
|
|
15643
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
15644
|
+
if (!value) return "";
|
|
15645
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
15646
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
15647
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
15648
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
15649
|
+
return d.toISOString().slice(0, 10);
|
|
15650
|
+
});
|
|
15651
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
15652
|
+
const v = Number(n);
|
|
15653
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
15654
|
+
});
|
|
15655
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
15656
|
+
const v = Number(cents);
|
|
15657
|
+
if (!Number.isFinite(v)) return "";
|
|
15658
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
15659
|
+
});
|
|
15660
|
+
hb.registerHelper(
|
|
15661
|
+
"pluralize",
|
|
15662
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
15663
|
+
);
|
|
15664
|
+
if (extra) {
|
|
15665
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
15666
|
+
hb.registerHelper(name, fn);
|
|
15478
15667
|
}
|
|
15479
15668
|
}
|
|
15669
|
+
return hb;
|
|
15480
15670
|
}
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
|
|
15671
|
+
|
|
15672
|
+
// src/server/runner/send.ts
|
|
15673
|
+
init_vars();
|
|
15674
|
+
|
|
15675
|
+
// src/server/runner/suppression.ts
|
|
15676
|
+
var SCOPES_BY_KIND = {
|
|
15677
|
+
marketing: ["all", "marketing"],
|
|
15678
|
+
transactional: ["all", "transactional"]
|
|
15679
|
+
};
|
|
15680
|
+
async function isSuppressed(collections, email, kind) {
|
|
15681
|
+
const normalized = email.toLowerCase();
|
|
15682
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
15683
|
+
const byEmail = await collections.suppressions.findOne({
|
|
15684
|
+
email: normalized,
|
|
15685
|
+
scope: { $in: allowed },
|
|
15686
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
15687
|
+
});
|
|
15688
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
15689
|
+
const hashed = await collections.suppressions.findOne({
|
|
15690
|
+
emailHash: sha256Hex(normalized),
|
|
15691
|
+
scope: { $in: allowed }
|
|
15692
|
+
});
|
|
15693
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
15694
|
+
return { suppressed: false };
|
|
15485
15695
|
}
|
|
15486
15696
|
|
|
15487
15697
|
// src/server/runner/send.ts
|
|
@@ -15549,9 +15759,11 @@ async function handleSend(run, step2, contact, flow, ctx) {
|
|
|
15549
15759
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
15550
15760
|
}
|
|
15551
15761
|
async function dispatchSend(sendId, ctx) {
|
|
15552
|
-
const send = await ctx.collections.sends.
|
|
15762
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
15763
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
15764
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15765
|
+
);
|
|
15553
15766
|
if (!send) return;
|
|
15554
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
15555
15767
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
15556
15768
|
if (!template) {
|
|
15557
15769
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -15568,6 +15780,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15568
15780
|
if (send.kind === "marketing") {
|
|
15569
15781
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15570
15782
|
if (bucket?.status === "tripped") {
|
|
15783
|
+
await ctx.collections.sends.updateOne(
|
|
15784
|
+
{ _id: send._id },
|
|
15785
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15786
|
+
);
|
|
15571
15787
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15572
15788
|
return;
|
|
15573
15789
|
}
|
|
@@ -15816,7 +16032,7 @@ async function handleWait(run, step2, ctx) {
|
|
|
15816
16032
|
await ctx.queues.advance.add(
|
|
15817
16033
|
"advance",
|
|
15818
16034
|
{ flowRunId: String(run._id) },
|
|
15819
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
16035
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
15820
16036
|
);
|
|
15821
16037
|
}
|
|
15822
16038
|
async function handleCondition(run, step2, contact, ctx) {
|
|
@@ -15928,7 +16144,7 @@ async function handleWebhookStep(run, step2, ctx) {
|
|
|
15928
16144
|
await ctx.queues.advance.add(
|
|
15929
16145
|
"advance",
|
|
15930
16146
|
{ flowRunId: String(run._id) },
|
|
15931
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
16147
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
15932
16148
|
);
|
|
15933
16149
|
}
|
|
15934
16150
|
}
|
|
@@ -15957,7 +16173,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
15957
16173
|
{ flowRunId: String(run._id) },
|
|
15958
16174
|
{
|
|
15959
16175
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15960
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
16176
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15961
16177
|
}
|
|
15962
16178
|
);
|
|
15963
16179
|
}
|
|
@@ -16012,13 +16228,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
16012
16228
|
let arr = steps;
|
|
16013
16229
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
16014
16230
|
const parentIndex = branchPath[i];
|
|
16015
|
-
const
|
|
16231
|
+
const branchKey2 = branchPath[i + 1];
|
|
16016
16232
|
const parent = arr[parentIndex];
|
|
16017
16233
|
if (!parent || parent.type !== "branch") return null;
|
|
16018
|
-
arr =
|
|
16234
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
16019
16235
|
}
|
|
16020
16236
|
return arr[currentStepIndex] ?? null;
|
|
16021
16237
|
}
|
|
16238
|
+
function branchKey(run) {
|
|
16239
|
+
return run.currentBranchPath.join("_");
|
|
16240
|
+
}
|
|
16022
16241
|
function unitToMs(value, unit) {
|
|
16023
16242
|
const m = 6e4;
|
|
16024
16243
|
switch (unit) {
|
|
@@ -16045,6 +16264,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16045
16264
|
}
|
|
16046
16265
|
}
|
|
16047
16266
|
}
|
|
16267
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16048
16268
|
async function processScheduledBroadcasts(ctx) {
|
|
16049
16269
|
const now = /* @__PURE__ */ new Date();
|
|
16050
16270
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -16055,15 +16275,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16055
16275
|
{ returnDocument: "after" }
|
|
16056
16276
|
);
|
|
16057
16277
|
if (!claimed) continue;
|
|
16058
|
-
|
|
16059
|
-
|
|
16060
|
-
|
|
16061
|
-
|
|
16062
|
-
|
|
16063
|
-
|
|
16064
|
-
|
|
16065
|
-
|
|
16278
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
16279
|
+
}
|
|
16280
|
+
}
|
|
16281
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
16282
|
+
if (ctx.config.queue.driver === "noop") {
|
|
16283
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16284
|
+
return;
|
|
16285
|
+
}
|
|
16286
|
+
await ctx.queues.advance.add(
|
|
16287
|
+
"advance",
|
|
16288
|
+
{ broadcastId: String(broadcast._id) },
|
|
16289
|
+
{
|
|
16290
|
+
attempts: 3,
|
|
16291
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
16292
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
16066
16293
|
}
|
|
16294
|
+
);
|
|
16295
|
+
}
|
|
16296
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
16297
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
16298
|
+
if (!broadcast) return;
|
|
16299
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16300
|
+
}
|
|
16301
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
16302
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16303
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16304
|
+
for (const b of stalled) {
|
|
16305
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16306
|
+
{ _id: b._id },
|
|
16307
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16308
|
+
);
|
|
16309
|
+
await startBroadcastDispatch(b, ctx);
|
|
16310
|
+
}
|
|
16311
|
+
}
|
|
16312
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
16313
|
+
try {
|
|
16314
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
16315
|
+
} catch (err) {
|
|
16316
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16317
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16318
|
+
{ _id: broadcast._id },
|
|
16319
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16320
|
+
);
|
|
16067
16321
|
}
|
|
16068
16322
|
}
|
|
16069
16323
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -16084,11 +16338,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16084
16338
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
16085
16339
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
16086
16340
|
for (; ; ) {
|
|
16341
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16342
|
+
{ _id: broadcast._id },
|
|
16343
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16344
|
+
);
|
|
16087
16345
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
16088
16346
|
if (page.contacts.length === 0) break;
|
|
16089
16347
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
16090
16348
|
if (eligible.length > 0) {
|
|
16091
16349
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16350
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16351
|
+
{ _id: broadcast._id },
|
|
16352
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16353
|
+
);
|
|
16092
16354
|
await sleep(2e3);
|
|
16093
16355
|
}
|
|
16094
16356
|
const sendDocs = await Promise.all(
|
|
@@ -16211,7 +16473,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16211
16473
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
16212
16474
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
16213
16475
|
if (respectTimezone && contact.timezone) {
|
|
16214
|
-
|
|
16476
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
16477
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
16215
16478
|
}
|
|
16216
16479
|
const doc = {
|
|
16217
16480
|
_id: sendId,
|
|
@@ -16250,7 +16513,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16250
16513
|
};
|
|
16251
16514
|
return { doc, delayMs };
|
|
16252
16515
|
}
|
|
16253
|
-
function
|
|
16516
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16517
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16254
16518
|
try {
|
|
16255
16519
|
const scheduled = new Date(scheduledMs);
|
|
16256
16520
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -16263,7 +16527,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
16263
16527
|
const utcMs = parse(utc);
|
|
16264
16528
|
const localMs = parse(local);
|
|
16265
16529
|
const offsetMs = utcMs - localMs;
|
|
16266
|
-
return offsetMs;
|
|
16530
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
16267
16531
|
} catch {
|
|
16268
16532
|
return 0;
|
|
16269
16533
|
}
|
|
@@ -16938,6 +17202,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
|
|
|
16938
17202
|
|
|
16939
17203
|
// src/server/runner/tick.ts
|
|
16940
17204
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
17205
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16941
17206
|
async function runTick(ctx) {
|
|
16942
17207
|
try {
|
|
16943
17208
|
await ctx.collections.health.updateOne(
|
|
@@ -16978,12 +17243,18 @@ async function runTick(ctx) {
|
|
|
16978
17243
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
16979
17244
|
console.error("mailery: broadcast dispatch failed", err);
|
|
16980
17245
|
});
|
|
17246
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17247
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17248
|
+
});
|
|
16981
17249
|
await evaluateHealth(ctx).catch((err) => {
|
|
16982
17250
|
console.error("mailery: health evaluation failed", err);
|
|
16983
17251
|
});
|
|
16984
17252
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
16985
17253
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
16986
17254
|
});
|
|
17255
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
17256
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
17257
|
+
});
|
|
16987
17258
|
await Promise.all([
|
|
16988
17259
|
runDnsblChecks(ctx).catch((err) => {
|
|
16989
17260
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -17054,140 +17325,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
17054
17325
|
await processScheduledBroadcasts(ctx);
|
|
17055
17326
|
}
|
|
17056
17327
|
|
|
17057
|
-
// src/server/runner/webhook.ts
|
|
17058
|
-
function dimsFromSend(send) {
|
|
17059
|
-
if (!send) return null;
|
|
17060
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
17061
|
-
}
|
|
17062
|
-
async function applyWebhookEvent(event, ctx) {
|
|
17063
|
-
const send = await ctx.collections.sends.findOne(
|
|
17064
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
17065
|
-
{ sort: { queuedAt: -1 } }
|
|
17066
|
-
);
|
|
17067
|
-
switch (event.type) {
|
|
17068
|
-
case "delivered":
|
|
17069
|
-
if (send) {
|
|
17070
|
-
await ctx.collections.sends.updateOne(
|
|
17071
|
-
{ _id: send._id },
|
|
17072
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
17073
|
-
);
|
|
17074
|
-
}
|
|
17075
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
17076
|
-
break;
|
|
17077
|
-
case "open":
|
|
17078
|
-
if (send) {
|
|
17079
|
-
await ctx.collections.sends.updateOne(
|
|
17080
|
-
{ _id: send._id },
|
|
17081
|
-
{
|
|
17082
|
-
$set: {
|
|
17083
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
17084
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
17085
|
-
},
|
|
17086
|
-
$inc: { openCount: 1 }
|
|
17087
|
-
}
|
|
17088
|
-
);
|
|
17089
|
-
}
|
|
17090
|
-
break;
|
|
17091
|
-
case "click":
|
|
17092
|
-
if (send) {
|
|
17093
|
-
await ctx.collections.sends.updateOne(
|
|
17094
|
-
{ _id: send._id },
|
|
17095
|
-
{
|
|
17096
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
17097
|
-
$inc: { clickCount: 1 },
|
|
17098
|
-
$push: {
|
|
17099
|
-
clickedLinks: {
|
|
17100
|
-
url: event.details.clickedUrl ?? "",
|
|
17101
|
-
linkId: "",
|
|
17102
|
-
clickedAt: event.occurredAt
|
|
17103
|
-
}
|
|
17104
|
-
}
|
|
17105
|
-
}
|
|
17106
|
-
);
|
|
17107
|
-
}
|
|
17108
|
-
break;
|
|
17109
|
-
case "bounce": {
|
|
17110
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
17111
|
-
if (send) {
|
|
17112
|
-
await ctx.collections.sends.updateOne(
|
|
17113
|
-
{ _id: send._id },
|
|
17114
|
-
{
|
|
17115
|
-
$set: {
|
|
17116
|
-
status: "bounced",
|
|
17117
|
-
bounceType,
|
|
17118
|
-
bounceReason: event.details.bounceReason ?? null
|
|
17119
|
-
}
|
|
17120
|
-
}
|
|
17121
|
-
);
|
|
17122
|
-
}
|
|
17123
|
-
if (bounceType === "hard") {
|
|
17124
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
17125
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17126
|
-
{ emailAtSubscribe: event.email },
|
|
17127
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17128
|
-
);
|
|
17129
|
-
}
|
|
17130
|
-
{
|
|
17131
|
-
const dims = dimsFromSend(send);
|
|
17132
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
17133
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
17134
|
-
}
|
|
17135
|
-
break;
|
|
17136
|
-
}
|
|
17137
|
-
case "complaint":
|
|
17138
|
-
case "spam_report":
|
|
17139
|
-
if (send) {
|
|
17140
|
-
await ctx.collections.sends.updateOne(
|
|
17141
|
-
{ _id: send._id },
|
|
17142
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
17143
|
-
);
|
|
17144
|
-
}
|
|
17145
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
17146
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17147
|
-
{ emailAtSubscribe: event.email },
|
|
17148
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17149
|
-
);
|
|
17150
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
17151
|
-
break;
|
|
17152
|
-
case "unsubscribe":
|
|
17153
|
-
if (send) {
|
|
17154
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
17155
|
-
}
|
|
17156
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
17157
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17158
|
-
{ emailAtSubscribe: event.email },
|
|
17159
|
-
{
|
|
17160
|
-
$set: {
|
|
17161
|
-
status: "unsubscribed",
|
|
17162
|
-
unsubscribedAt: event.occurredAt,
|
|
17163
|
-
unsubscribeReason: "user_request",
|
|
17164
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
17165
|
-
}
|
|
17166
|
-
}
|
|
17167
|
-
);
|
|
17168
|
-
break;
|
|
17169
|
-
}
|
|
17170
|
-
}
|
|
17171
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
17172
|
-
const normalized = email.toLowerCase();
|
|
17173
|
-
await ctx.collections.suppressions.updateOne(
|
|
17174
|
-
{ email: normalized, scope },
|
|
17175
|
-
{
|
|
17176
|
-
$setOnInsert: {
|
|
17177
|
-
email: normalized,
|
|
17178
|
-
emailHash: sha256Hex(normalized),
|
|
17179
|
-
scope,
|
|
17180
|
-
reason,
|
|
17181
|
-
source: "provider_webhook",
|
|
17182
|
-
notes: null,
|
|
17183
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
17184
|
-
expiresAt: null
|
|
17185
|
-
}
|
|
17186
|
-
},
|
|
17187
|
-
{ upsert: true }
|
|
17188
|
-
);
|
|
17189
|
-
}
|
|
17190
|
-
|
|
17191
17328
|
// src/server/mailer.ts
|
|
17192
17329
|
var Mailer = class _Mailer {
|
|
17193
17330
|
db;
|
|
@@ -17652,41 +17789,48 @@ var Mailer = class _Mailer {
|
|
|
17652
17789
|
if (existing) return { sendId: String(existing._id) };
|
|
17653
17790
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
17654
17791
|
const sendId = new mongodb.ObjectId();
|
|
17655
|
-
|
|
17656
|
-
|
|
17657
|
-
|
|
17658
|
-
|
|
17659
|
-
|
|
17660
|
-
|
|
17661
|
-
|
|
17662
|
-
|
|
17663
|
-
|
|
17664
|
-
|
|
17665
|
-
|
|
17666
|
-
|
|
17667
|
-
|
|
17668
|
-
|
|
17669
|
-
|
|
17670
|
-
|
|
17671
|
-
|
|
17672
|
-
|
|
17673
|
-
|
|
17674
|
-
|
|
17675
|
-
|
|
17676
|
-
|
|
17677
|
-
|
|
17678
|
-
|
|
17679
|
-
|
|
17680
|
-
|
|
17681
|
-
|
|
17682
|
-
|
|
17683
|
-
|
|
17684
|
-
|
|
17685
|
-
|
|
17686
|
-
|
|
17687
|
-
|
|
17688
|
-
|
|
17689
|
-
|
|
17792
|
+
try {
|
|
17793
|
+
await this.collections.sends.insertOne({
|
|
17794
|
+
_id: sendId,
|
|
17795
|
+
dedupeKey,
|
|
17796
|
+
externalId: parsed.externalId,
|
|
17797
|
+
emailAtSend: contact.email,
|
|
17798
|
+
templateId: template._id,
|
|
17799
|
+
templateSlug: template.slug,
|
|
17800
|
+
flowRunId: null,
|
|
17801
|
+
broadcastId: null,
|
|
17802
|
+
manualSendBy: "sendOneOff",
|
|
17803
|
+
kind: template.kind,
|
|
17804
|
+
provider: providerName,
|
|
17805
|
+
providerMessageId: null,
|
|
17806
|
+
fromName: template.fromName,
|
|
17807
|
+
fromEmail: template.fromEmail,
|
|
17808
|
+
subject: template.subject,
|
|
17809
|
+
bodyHash: "",
|
|
17810
|
+
status: "queued",
|
|
17811
|
+
errorMessage: null,
|
|
17812
|
+
bounceType: null,
|
|
17813
|
+
bounceReason: null,
|
|
17814
|
+
links: [],
|
|
17815
|
+
vars: parsed.vars ?? {},
|
|
17816
|
+
openedAt: null,
|
|
17817
|
+
openCount: 0,
|
|
17818
|
+
firstClickAt: null,
|
|
17819
|
+
clickCount: 0,
|
|
17820
|
+
clickedLinks: [],
|
|
17821
|
+
unsubscribedAt: null,
|
|
17822
|
+
complainedAt: null,
|
|
17823
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
17824
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17825
|
+
sentAt: null,
|
|
17826
|
+
deliveredAt: null
|
|
17827
|
+
});
|
|
17828
|
+
} catch (err) {
|
|
17829
|
+
if (err?.code !== 11e3) throw err;
|
|
17830
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
17831
|
+
if (winner) return { sendId: String(winner._id) };
|
|
17832
|
+
throw err;
|
|
17833
|
+
}
|
|
17690
17834
|
await this.queues.send.add(
|
|
17691
17835
|
"send",
|
|
17692
17836
|
{ sendId: String(sendId) },
|
|
@@ -17730,7 +17874,12 @@ var Mailer = class _Mailer {
|
|
|
17730
17874
|
await runTick(this.runnerContext);
|
|
17731
17875
|
},
|
|
17732
17876
|
advance: async (data) => {
|
|
17733
|
-
if (
|
|
17877
|
+
if (data.broadcastId) {
|
|
17878
|
+
if (!mongodb.ObjectId.isValid(data.broadcastId)) return;
|
|
17879
|
+
await dispatchBroadcastById(new mongodb.ObjectId(data.broadcastId), this.runnerContext);
|
|
17880
|
+
return;
|
|
17881
|
+
}
|
|
17882
|
+
if (!data.flowRunId || !mongodb.ObjectId.isValid(data.flowRunId)) return;
|
|
17734
17883
|
await processOneRunStep(new mongodb.ObjectId(data.flowRunId), this.runnerContext);
|
|
17735
17884
|
},
|
|
17736
17885
|
send: async (data) => {
|
|
@@ -17746,27 +17895,7 @@ var Mailer = class _Mailer {
|
|
|
17746
17895
|
}
|
|
17747
17896
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
17748
17897
|
async processWebhookBacklog() {
|
|
17749
|
-
|
|
17750
|
-
for (const evt of batch) {
|
|
17751
|
-
try {
|
|
17752
|
-
const normalized = evt.raw?.normalized;
|
|
17753
|
-
const details = normalized?.details ?? {};
|
|
17754
|
-
await applyWebhookEvent(
|
|
17755
|
-
{
|
|
17756
|
-
type: evt.normalizedType,
|
|
17757
|
-
providerEventId: evt.providerEventId,
|
|
17758
|
-
providerMessageId: evt.providerMessageId,
|
|
17759
|
-
email: evt.email,
|
|
17760
|
-
occurredAt: evt.occurredAt,
|
|
17761
|
-
details
|
|
17762
|
-
},
|
|
17763
|
-
this.runnerContext
|
|
17764
|
-
);
|
|
17765
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
17766
|
-
} catch (err) {
|
|
17767
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
17768
|
-
}
|
|
17769
|
-
}
|
|
17898
|
+
await processWebhookBacklog(this.runnerContext);
|
|
17770
17899
|
}
|
|
17771
17900
|
async stop() {
|
|
17772
17901
|
await this.queueDriver.close();
|