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.js
CHANGED
|
@@ -14434,6 +14434,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14434
14434
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
14435
14435
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
14436
14436
|
{ key: { name: 1, occurredAt: -1 } },
|
|
14437
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
14437
14438
|
{ key: { externalId: 1, name: 1 } }
|
|
14438
14439
|
]),
|
|
14439
14440
|
c.flows.createIndexes([
|
|
@@ -14444,7 +14445,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14444
14445
|
c.flowRuns.createIndexes([
|
|
14445
14446
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
14446
14447
|
{ key: { externalId: 1, flowId: 1 } },
|
|
14447
|
-
{ key: { flowId: 1, status: 1 } }
|
|
14448
|
+
{ key: { flowId: 1, status: 1 } },
|
|
14449
|
+
{
|
|
14450
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
14451
|
+
unique: true,
|
|
14452
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
14453
|
+
}
|
|
14448
14454
|
]),
|
|
14449
14455
|
c.templates.createIndexes([
|
|
14450
14456
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -14632,7 +14638,13 @@ var BullDriver = class _BullDriver {
|
|
|
14632
14638
|
constructor(bull, redis) {
|
|
14633
14639
|
this.bull = bull;
|
|
14634
14640
|
this.redis = redis;
|
|
14635
|
-
const opts = {
|
|
14641
|
+
const opts = {
|
|
14642
|
+
connection: redis,
|
|
14643
|
+
defaultJobOptions: {
|
|
14644
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
14645
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
14646
|
+
}
|
|
14647
|
+
};
|
|
14636
14648
|
this.bullQueues = {
|
|
14637
14649
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
14638
14650
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -14650,7 +14662,11 @@ var BullDriver = class _BullDriver {
|
|
|
14650
14662
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
14651
14663
|
"mailer-tick-repeat",
|
|
14652
14664
|
{ every: intervalSeconds * 1e3 },
|
|
14653
|
-
{
|
|
14665
|
+
{
|
|
14666
|
+
name: "tick",
|
|
14667
|
+
data: {},
|
|
14668
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
14669
|
+
}
|
|
14654
14670
|
);
|
|
14655
14671
|
}
|
|
14656
14672
|
async startWorkers(opts) {
|
|
@@ -14760,9 +14776,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14760
14776
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
14761
14777
|
);
|
|
14762
14778
|
}
|
|
14779
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
14763
14780
|
const backend = new backendMod.MongoBackend({
|
|
14764
14781
|
mongo: opts.db,
|
|
14765
|
-
collection:
|
|
14782
|
+
collection: collectionName
|
|
14766
14783
|
});
|
|
14767
14784
|
const agenda = new agendaMod.Agenda({
|
|
14768
14785
|
backend,
|
|
@@ -14771,13 +14788,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14771
14788
|
maxConcurrency: 50,
|
|
14772
14789
|
defaultConcurrency: 5
|
|
14773
14790
|
});
|
|
14774
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
14791
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
14775
14792
|
}
|
|
14776
14793
|
db;
|
|
14777
|
-
|
|
14794
|
+
collName;
|
|
14795
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
14778
14796
|
this.agenda = agenda;
|
|
14779
14797
|
this.agendaMod = agendaMod;
|
|
14780
14798
|
this.db = db;
|
|
14799
|
+
this.collName = collectionName;
|
|
14781
14800
|
this.queues = {
|
|
14782
14801
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
14783
14802
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -14810,10 +14829,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14810
14829
|
}
|
|
14811
14830
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
14812
14831
|
jobsCollection() {
|
|
14813
|
-
return this.db.collection(this.
|
|
14814
|
-
}
|
|
14815
|
-
collectionName() {
|
|
14816
|
-
return "_mailerJobs";
|
|
14832
|
+
return this.db.collection(this.collName);
|
|
14817
14833
|
}
|
|
14818
14834
|
async findPending(name, jobId) {
|
|
14819
14835
|
return this.jobsCollection().findOne({
|
|
@@ -14823,12 +14839,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14823
14839
|
});
|
|
14824
14840
|
}
|
|
14825
14841
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
14826
|
-
if (!this.started) {
|
|
14827
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
14828
|
-
}, { concurrency: 1 });
|
|
14829
|
-
await this.agenda.start();
|
|
14830
|
-
this.started = true;
|
|
14831
|
-
}
|
|
14832
14842
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
14833
14843
|
}
|
|
14834
14844
|
async startWorkers(opts) {
|
|
@@ -14931,6 +14941,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
14931
14941
|
|
|
14932
14942
|
// src/server/runner/triggers.ts
|
|
14933
14943
|
var BATCH_SIZE = 1e3;
|
|
14944
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
14934
14945
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
14935
14946
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
14936
14947
|
for (const flow of flows) {
|
|
@@ -14941,16 +14952,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
14941
14952
|
const eventName = flow.trigger.eventName;
|
|
14942
14953
|
if (!eventName) return;
|
|
14943
14954
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
14944
|
-
const
|
|
14955
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
14956
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
14945
14957
|
if (events.length === 0) return;
|
|
14946
14958
|
for (const event of events) {
|
|
14947
14959
|
await tryEnterFlow(flow, event, ctx);
|
|
14948
14960
|
}
|
|
14949
|
-
const
|
|
14950
|
-
|
|
14951
|
-
|
|
14952
|
-
|
|
14953
|
-
|
|
14961
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
14962
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
14963
|
+
await ctx.collections.flows.updateOne(
|
|
14964
|
+
{ _id: flow._id },
|
|
14965
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
14966
|
+
);
|
|
14967
|
+
}
|
|
14954
14968
|
}
|
|
14955
14969
|
async function tryEnterFlow(flow, event, ctx) {
|
|
14956
14970
|
if (flow.trigger.once) {
|
|
@@ -14962,120 +14976,456 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14962
14976
|
}
|
|
14963
14977
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
14964
14978
|
if (!sub || sub.status !== "subscribed") return;
|
|
14965
|
-
|
|
14966
|
-
|
|
14967
|
-
|
|
14968
|
-
|
|
14969
|
-
|
|
14970
|
-
|
|
14971
|
-
|
|
14972
|
-
|
|
14973
|
-
|
|
14974
|
-
|
|
14975
|
-
|
|
14976
|
-
|
|
14977
|
-
|
|
14978
|
-
|
|
14979
|
-
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14979
|
+
let result;
|
|
14980
|
+
try {
|
|
14981
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
14982
|
+
externalId: event.externalId,
|
|
14983
|
+
flowId: flow._id,
|
|
14984
|
+
flowSlug: flow.slug,
|
|
14985
|
+
flowVersion: flow.version,
|
|
14986
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
14987
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
14988
|
+
triggerDedupeKey: event.dedupeKey,
|
|
14989
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
14990
|
+
status: "active",
|
|
14991
|
+
currentStepIndex: 0,
|
|
14992
|
+
currentBranchPath: [],
|
|
14993
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
14994
|
+
attemptsForCurrentStep: 0,
|
|
14995
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
14996
|
+
exitedAt: null,
|
|
14997
|
+
exitReason: null,
|
|
14998
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
14999
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15000
|
+
});
|
|
15001
|
+
} catch (err) {
|
|
15002
|
+
if (err?.code !== 11e3) throw err;
|
|
15003
|
+
return;
|
|
15004
|
+
}
|
|
14984
15005
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
14985
15006
|
}
|
|
14986
15007
|
|
|
14987
|
-
// src/server/
|
|
14988
|
-
|
|
14989
|
-
|
|
14990
|
-
const
|
|
14991
|
-
if (
|
|
14992
|
-
|
|
14993
|
-
|
|
14994
|
-
|
|
14995
|
-
|
|
14996
|
-
|
|
14997
|
-
|
|
14998
|
-
|
|
14999
|
-
|
|
15000
|
-
|
|
15001
|
-
|
|
15002
|
-
|
|
15003
|
-
|
|
15004
|
-
|
|
15005
|
-
|
|
15006
|
-
|
|
15007
|
-
|
|
15008
|
-
|
|
15009
|
-
|
|
15010
|
-
|
|
15011
|
-
|
|
15012
|
-
|
|
15013
|
-
|
|
15014
|
-
|
|
15015
|
-
if (
|
|
15016
|
-
|
|
15017
|
-
|
|
15018
|
-
|
|
15019
|
-
|
|
15020
|
-
}
|
|
15021
|
-
if ("any" in p) {
|
|
15022
|
-
for (const sub of p.any) {
|
|
15023
|
-
if (await evaluatePredicate(sub, ctx)) return true;
|
|
15008
|
+
// src/server/templates/sender-domain.ts
|
|
15009
|
+
function extractDomain(email) {
|
|
15010
|
+
if (typeof email !== "string") return null;
|
|
15011
|
+
const at = email.lastIndexOf("@");
|
|
15012
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15013
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15014
|
+
}
|
|
15015
|
+
|
|
15016
|
+
// src/server/runner/health.ts
|
|
15017
|
+
var ZERO_COUNTERS = {
|
|
15018
|
+
sent: 0,
|
|
15019
|
+
delivered: 0,
|
|
15020
|
+
bounced: 0,
|
|
15021
|
+
hardBounced: 0,
|
|
15022
|
+
softBounced: 0,
|
|
15023
|
+
complained: 0,
|
|
15024
|
+
failedToSend: 0
|
|
15025
|
+
};
|
|
15026
|
+
var ZERO_RATES = {
|
|
15027
|
+
bounceRate: 0,
|
|
15028
|
+
hardBounceRate: 0,
|
|
15029
|
+
complaintRate: 0,
|
|
15030
|
+
failureRate: 0
|
|
15031
|
+
};
|
|
15032
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15033
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15034
|
+
const writes = [];
|
|
15035
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15036
|
+
if (dims) {
|
|
15037
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15038
|
+
if (domain) {
|
|
15039
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15040
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15024
15041
|
}
|
|
15025
|
-
return false;
|
|
15026
|
-
}
|
|
15027
|
-
if ("not" in p) {
|
|
15028
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
15029
15042
|
}
|
|
15030
|
-
|
|
15043
|
+
await Promise.all(writes);
|
|
15031
15044
|
}
|
|
15032
|
-
async function
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15037
|
-
|
|
15045
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15046
|
+
await ctx.collections.health.updateOne(
|
|
15047
|
+
{ _id },
|
|
15048
|
+
{
|
|
15049
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
15050
|
+
$setOnInsert: {
|
|
15051
|
+
_id,
|
|
15052
|
+
senderDomain,
|
|
15053
|
+
kind,
|
|
15054
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15055
|
+
windowDurationMs: windowMs,
|
|
15056
|
+
status: "healthy",
|
|
15057
|
+
trippedAt: null,
|
|
15058
|
+
trippedReason: null,
|
|
15059
|
+
manuallyResumedAt: null,
|
|
15060
|
+
rates: { ...ZERO_RATES }
|
|
15061
|
+
},
|
|
15062
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15063
|
+
},
|
|
15064
|
+
{ upsert: true }
|
|
15065
|
+
);
|
|
15038
15066
|
}
|
|
15039
|
-
async function
|
|
15040
|
-
const
|
|
15041
|
-
|
|
15042
|
-
|
|
15043
|
-
if (
|
|
15044
|
-
const
|
|
15045
|
-
if (
|
|
15046
|
-
|
|
15047
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
15067
|
+
async function evaluateHealth(ctx) {
|
|
15068
|
+
const cb = ctx.config.circuitBreaker;
|
|
15069
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15070
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15071
|
+
if (docs.length === 0) return;
|
|
15072
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15073
|
+
if (!hasAgg) {
|
|
15074
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15048
15075
|
}
|
|
15049
|
-
const
|
|
15050
|
-
|
|
15051
|
-
|
|
15052
|
-
if (
|
|
15053
|
-
|
|
15076
|
+
for (const doc of docs) {
|
|
15077
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15078
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15079
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15080
|
+
await ctx.collections.health.updateOne(
|
|
15081
|
+
{ _id: doc._id },
|
|
15082
|
+
{
|
|
15083
|
+
$set: {
|
|
15084
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15085
|
+
windowDurationMs: windowMs,
|
|
15086
|
+
counters: { ...ZERO_COUNTERS },
|
|
15087
|
+
rates: { ...ZERO_RATES },
|
|
15088
|
+
status: "healthy",
|
|
15089
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15090
|
+
}
|
|
15091
|
+
}
|
|
15092
|
+
);
|
|
15054
15093
|
continue;
|
|
15055
15094
|
}
|
|
15056
|
-
const
|
|
15057
|
-
|
|
15095
|
+
const c = doc.counters;
|
|
15096
|
+
const total = c.sent || 1;
|
|
15097
|
+
const rates = {
|
|
15098
|
+
bounceRate: c.bounced / total,
|
|
15099
|
+
hardBounceRate: c.hardBounced / total,
|
|
15100
|
+
complaintRate: c.complained / total,
|
|
15101
|
+
failureRate: c.failedToSend / total
|
|
15102
|
+
};
|
|
15103
|
+
await ctx.collections.health.updateOne(
|
|
15104
|
+
{ _id: doc._id },
|
|
15105
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15106
|
+
);
|
|
15107
|
+
if (isAgg) continue;
|
|
15108
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15109
|
+
if (doc.status === "tripped") continue;
|
|
15110
|
+
let trippedReason = null;
|
|
15111
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15112
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15113
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15114
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15115
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15116
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15117
|
+
}
|
|
15118
|
+
if (trippedReason) {
|
|
15119
|
+
const result = await ctx.collections.health.updateOne(
|
|
15120
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15121
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15122
|
+
);
|
|
15123
|
+
if (result.modifiedCount > 0) {
|
|
15124
|
+
if (ctx.audit) {
|
|
15125
|
+
try {
|
|
15126
|
+
await ctx.audit({
|
|
15127
|
+
actor: "system:circuit-breaker",
|
|
15128
|
+
action: "health.trip",
|
|
15129
|
+
resource: {
|
|
15130
|
+
collection: "mailer_health",
|
|
15131
|
+
id: String(doc._id),
|
|
15132
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15133
|
+
},
|
|
15134
|
+
diffSummary: trippedReason
|
|
15135
|
+
});
|
|
15136
|
+
} catch {
|
|
15137
|
+
}
|
|
15138
|
+
}
|
|
15139
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15140
|
+
try {
|
|
15141
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15142
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15143
|
+
rates
|
|
15144
|
+
});
|
|
15145
|
+
} catch {
|
|
15146
|
+
}
|
|
15147
|
+
}
|
|
15148
|
+
}
|
|
15149
|
+
continue;
|
|
15150
|
+
}
|
|
15151
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15152
|
+
if (doc.status !== "degraded") {
|
|
15153
|
+
await ctx.collections.health.updateOne(
|
|
15154
|
+
{ _id: doc._id },
|
|
15155
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15156
|
+
);
|
|
15157
|
+
}
|
|
15158
|
+
} else if (doc.status === "degraded") {
|
|
15159
|
+
await ctx.collections.health.updateOne(
|
|
15160
|
+
{ _id: doc._id },
|
|
15161
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15162
|
+
);
|
|
15163
|
+
}
|
|
15058
15164
|
}
|
|
15059
|
-
return n;
|
|
15060
15165
|
}
|
|
15061
|
-
function
|
|
15062
|
-
const
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15066
|
-
}
|
|
15067
|
-
return null;
|
|
15166
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15167
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15168
|
+
const id = healthBucketId(domain, kind);
|
|
15169
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15068
15170
|
}
|
|
15069
15171
|
|
|
15070
|
-
// src/server/runner/
|
|
15071
|
-
|
|
15072
|
-
|
|
15073
|
-
|
|
15074
|
-
|
|
15075
|
-
|
|
15076
|
-
|
|
15077
|
-
|
|
15078
|
-
|
|
15172
|
+
// src/server/runner/webhook.ts
|
|
15173
|
+
function dimsFromSend(send) {
|
|
15174
|
+
if (!send) return null;
|
|
15175
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
15176
|
+
}
|
|
15177
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
15178
|
+
const filter = { processed: false };
|
|
15179
|
+
if (opts.olderThanMs) {
|
|
15180
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
15181
|
+
}
|
|
15182
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
15183
|
+
for (const evt of batch) {
|
|
15184
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
15185
|
+
{ _id: evt._id, processed: false },
|
|
15186
|
+
{ $set: { processed: true } }
|
|
15187
|
+
);
|
|
15188
|
+
if (!claimed) continue;
|
|
15189
|
+
try {
|
|
15190
|
+
const normalized = evt.raw?.normalized;
|
|
15191
|
+
const details = normalized?.details ?? {};
|
|
15192
|
+
await applyWebhookEvent(
|
|
15193
|
+
{
|
|
15194
|
+
type: evt.normalizedType,
|
|
15195
|
+
providerEventId: evt.providerEventId,
|
|
15196
|
+
providerMessageId: evt.providerMessageId,
|
|
15197
|
+
email: evt.email,
|
|
15198
|
+
occurredAt: evt.occurredAt,
|
|
15199
|
+
details
|
|
15200
|
+
},
|
|
15201
|
+
ctx
|
|
15202
|
+
);
|
|
15203
|
+
} catch (err) {
|
|
15204
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
15205
|
+
}
|
|
15206
|
+
}
|
|
15207
|
+
}
|
|
15208
|
+
async function applyWebhookEvent(event, ctx) {
|
|
15209
|
+
const send = await ctx.collections.sends.findOne(
|
|
15210
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
15211
|
+
{ sort: { queuedAt: -1 } }
|
|
15212
|
+
);
|
|
15213
|
+
switch (event.type) {
|
|
15214
|
+
case "delivered":
|
|
15215
|
+
if (send) {
|
|
15216
|
+
await ctx.collections.sends.updateOne(
|
|
15217
|
+
{ _id: send._id },
|
|
15218
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
15219
|
+
);
|
|
15220
|
+
}
|
|
15221
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
15222
|
+
break;
|
|
15223
|
+
case "open":
|
|
15224
|
+
if (send) {
|
|
15225
|
+
await ctx.collections.sends.updateOne(
|
|
15226
|
+
{ _id: send._id },
|
|
15227
|
+
{
|
|
15228
|
+
$set: {
|
|
15229
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
15230
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
15231
|
+
},
|
|
15232
|
+
$inc: { openCount: 1 }
|
|
15233
|
+
}
|
|
15234
|
+
);
|
|
15235
|
+
}
|
|
15236
|
+
break;
|
|
15237
|
+
case "click":
|
|
15238
|
+
if (send) {
|
|
15239
|
+
await ctx.collections.sends.updateOne(
|
|
15240
|
+
{ _id: send._id },
|
|
15241
|
+
{
|
|
15242
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
15243
|
+
$inc: { clickCount: 1 },
|
|
15244
|
+
$push: {
|
|
15245
|
+
clickedLinks: {
|
|
15246
|
+
url: event.details.clickedUrl ?? "",
|
|
15247
|
+
linkId: "",
|
|
15248
|
+
clickedAt: event.occurredAt
|
|
15249
|
+
}
|
|
15250
|
+
}
|
|
15251
|
+
}
|
|
15252
|
+
);
|
|
15253
|
+
}
|
|
15254
|
+
break;
|
|
15255
|
+
case "bounce": {
|
|
15256
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
15257
|
+
if (send) {
|
|
15258
|
+
await ctx.collections.sends.updateOne(
|
|
15259
|
+
{ _id: send._id },
|
|
15260
|
+
{
|
|
15261
|
+
$set: {
|
|
15262
|
+
status: "bounced",
|
|
15263
|
+
bounceType,
|
|
15264
|
+
bounceReason: event.details.bounceReason ?? null
|
|
15265
|
+
}
|
|
15266
|
+
}
|
|
15267
|
+
);
|
|
15268
|
+
}
|
|
15269
|
+
if (bounceType === "hard") {
|
|
15270
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
15271
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15272
|
+
{ emailAtSubscribe: event.email },
|
|
15273
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15274
|
+
);
|
|
15275
|
+
}
|
|
15276
|
+
{
|
|
15277
|
+
const dims = dimsFromSend(send);
|
|
15278
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
15279
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
15280
|
+
}
|
|
15281
|
+
break;
|
|
15282
|
+
}
|
|
15283
|
+
case "complaint":
|
|
15284
|
+
case "spam_report":
|
|
15285
|
+
if (send) {
|
|
15286
|
+
await ctx.collections.sends.updateOne(
|
|
15287
|
+
{ _id: send._id },
|
|
15288
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
15289
|
+
);
|
|
15290
|
+
}
|
|
15291
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
15292
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15293
|
+
{ emailAtSubscribe: event.email },
|
|
15294
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15295
|
+
);
|
|
15296
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
15297
|
+
break;
|
|
15298
|
+
case "unsubscribe":
|
|
15299
|
+
if (send) {
|
|
15300
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
15301
|
+
}
|
|
15302
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
15303
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15304
|
+
{ emailAtSubscribe: event.email },
|
|
15305
|
+
{
|
|
15306
|
+
$set: {
|
|
15307
|
+
status: "unsubscribed",
|
|
15308
|
+
unsubscribedAt: event.occurredAt,
|
|
15309
|
+
unsubscribeReason: "user_request",
|
|
15310
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15311
|
+
}
|
|
15312
|
+
}
|
|
15313
|
+
);
|
|
15314
|
+
break;
|
|
15315
|
+
}
|
|
15316
|
+
}
|
|
15317
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
15318
|
+
const normalized = email.toLowerCase();
|
|
15319
|
+
await ctx.collections.suppressions.updateOne(
|
|
15320
|
+
{ email: normalized, scope },
|
|
15321
|
+
{
|
|
15322
|
+
$setOnInsert: {
|
|
15323
|
+
email: normalized,
|
|
15324
|
+
emailHash: sha256Hex(normalized),
|
|
15325
|
+
scope,
|
|
15326
|
+
reason,
|
|
15327
|
+
source: "provider_webhook",
|
|
15328
|
+
notes: null,
|
|
15329
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
15330
|
+
expiresAt: null
|
|
15331
|
+
}
|
|
15332
|
+
},
|
|
15333
|
+
{ upsert: true }
|
|
15334
|
+
);
|
|
15335
|
+
}
|
|
15336
|
+
|
|
15337
|
+
// src/server/runner/predicate.ts
|
|
15338
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
15339
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
15340
|
+
const p = predicate;
|
|
15341
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
15342
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
15343
|
+
if ("fieldEquals" in p) {
|
|
15344
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
15345
|
+
}
|
|
15346
|
+
if ("fieldExists" in p) {
|
|
15347
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
15348
|
+
}
|
|
15349
|
+
if ("subscriptionStatus" in p) {
|
|
15350
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
15351
|
+
return sub?.status === p.subscriptionStatus;
|
|
15352
|
+
}
|
|
15353
|
+
if ("hasFiredEvent" in p) {
|
|
15354
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
15355
|
+
}
|
|
15356
|
+
if ("notHasFiredEvent" in p) {
|
|
15357
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
15358
|
+
}
|
|
15359
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
15360
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
15361
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
15362
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
15363
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
15364
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
15365
|
+
if ("all" in p) {
|
|
15366
|
+
for (const sub of p.all) {
|
|
15367
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
15368
|
+
}
|
|
15369
|
+
return true;
|
|
15370
|
+
}
|
|
15371
|
+
if ("any" in p) {
|
|
15372
|
+
for (const sub of p.any) {
|
|
15373
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
15374
|
+
}
|
|
15375
|
+
return false;
|
|
15376
|
+
}
|
|
15377
|
+
if ("not" in p) {
|
|
15378
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
15379
|
+
}
|
|
15380
|
+
return false;
|
|
15381
|
+
}
|
|
15382
|
+
async function hasEvent(ctx, name, opts) {
|
|
15383
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
15384
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15385
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
15386
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
15387
|
+
return !!found;
|
|
15388
|
+
}
|
|
15389
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
15390
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
15391
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
15392
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
15393
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
15394
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15395
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
15396
|
+
if (!excludeBots) {
|
|
15397
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
15398
|
+
}
|
|
15399
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
15400
|
+
let n = 0;
|
|
15401
|
+
for (const s of docs) {
|
|
15402
|
+
if (kind === "opened") {
|
|
15403
|
+
n++;
|
|
15404
|
+
continue;
|
|
15405
|
+
}
|
|
15406
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
15407
|
+
if (hasHumanClick) n++;
|
|
15408
|
+
}
|
|
15409
|
+
return n;
|
|
15410
|
+
}
|
|
15411
|
+
function effectiveLowerBound(ctx, opts) {
|
|
15412
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
15413
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
15414
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
15415
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15416
|
+
}
|
|
15417
|
+
return null;
|
|
15418
|
+
}
|
|
15419
|
+
|
|
15420
|
+
// src/server/runner/delivery-window.ts
|
|
15421
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15422
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15423
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15424
|
+
let candidate = now;
|
|
15425
|
+
if (window2.timeOfDay) {
|
|
15426
|
+
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15427
|
+
const local = localParts(candidate, tz);
|
|
15428
|
+
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
15079
15429
|
if (candidate.getTime() < todaySlot.getTime()) {
|
|
15080
15430
|
candidate = todaySlot;
|
|
15081
15431
|
} else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
|
|
@@ -15207,7 +15557,8 @@ function applyTracking(html, opts) {
|
|
|
15207
15557
|
const seen = /* @__PURE__ */ new Map();
|
|
15208
15558
|
let out = html;
|
|
15209
15559
|
if (opts.trackClicks) {
|
|
15210
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
15560
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
15561
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
15211
15562
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
15212
15563
|
let linkId = seen.get(url);
|
|
15213
15564
|
if (!linkId) {
|
|
@@ -15239,236 +15590,95 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
15239
15590
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
15240
15591
|
return false;
|
|
15241
15592
|
}
|
|
15242
|
-
|
|
15243
|
-
|
|
15244
|
-
|
|
15245
|
-
|
|
15246
|
-
|
|
15247
|
-
|
|
15248
|
-
|
|
15249
|
-
hb.registerHelper("gt", (a, b) => a > b);
|
|
15250
|
-
hb.registerHelper("lt", (a, b) => a < b);
|
|
15251
|
-
hb.registerHelper("gte", (a, b) => a >= b);
|
|
15252
|
-
hb.registerHelper("lte", (a, b) => a <= b);
|
|
15253
|
-
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
15254
|
-
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
15255
|
-
hb.registerHelper("not", (a) => !a);
|
|
15256
|
-
hb.registerHelper("formatDate", (value, fmt) => {
|
|
15257
|
-
if (!value) return "";
|
|
15258
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
15259
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
15260
|
-
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
15261
|
-
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
15262
|
-
return d.toISOString().slice(0, 10);
|
|
15263
|
-
});
|
|
15264
|
-
hb.registerHelper("formatNumber", (n) => {
|
|
15265
|
-
const v = Number(n);
|
|
15266
|
-
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
15267
|
-
});
|
|
15268
|
-
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
15269
|
-
const v = Number(cents);
|
|
15270
|
-
if (!Number.isFinite(v)) return "";
|
|
15271
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
15272
|
-
});
|
|
15273
|
-
hb.registerHelper(
|
|
15274
|
-
"pluralize",
|
|
15275
|
-
(n, one, many) => Number(n) === 1 ? one : many
|
|
15276
|
-
);
|
|
15277
|
-
if (extra) {
|
|
15278
|
-
for (const [name, fn] of Object.entries(extra)) {
|
|
15279
|
-
hb.registerHelper(name, fn);
|
|
15280
|
-
}
|
|
15281
|
-
}
|
|
15282
|
-
return hb;
|
|
15283
|
-
}
|
|
15284
|
-
|
|
15285
|
-
// src/server/runner/send.ts
|
|
15286
|
-
init_vars();
|
|
15287
|
-
|
|
15288
|
-
// src/server/runner/suppression.ts
|
|
15289
|
-
var SCOPES_BY_KIND = {
|
|
15290
|
-
marketing: ["all", "marketing"],
|
|
15291
|
-
transactional: ["all", "transactional"]
|
|
15593
|
+
var NAMED_ENTITIES = {
|
|
15594
|
+
amp: "&",
|
|
15595
|
+
lt: "<",
|
|
15596
|
+
gt: ">",
|
|
15597
|
+
quot: '"',
|
|
15598
|
+
apos: "'",
|
|
15599
|
+
nbsp: "\xA0"
|
|
15292
15600
|
};
|
|
15293
|
-
|
|
15294
|
-
|
|
15295
|
-
|
|
15296
|
-
|
|
15297
|
-
|
|
15298
|
-
|
|
15299
|
-
|
|
15300
|
-
|
|
15301
|
-
|
|
15302
|
-
const hashed = await collections.suppressions.findOne({
|
|
15303
|
-
emailHash: sha256Hex(normalized),
|
|
15304
|
-
scope: { $in: allowed }
|
|
15305
|
-
});
|
|
15306
|
-
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
15307
|
-
return { suppressed: false };
|
|
15308
|
-
}
|
|
15309
|
-
|
|
15310
|
-
// src/server/templates/sender-domain.ts
|
|
15311
|
-
function extractDomain(email) {
|
|
15312
|
-
if (typeof email !== "string") return null;
|
|
15313
|
-
const at = email.lastIndexOf("@");
|
|
15314
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
15315
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
15316
|
-
}
|
|
15317
|
-
|
|
15318
|
-
// src/server/runner/health.ts
|
|
15319
|
-
var ZERO_COUNTERS = {
|
|
15320
|
-
sent: 0,
|
|
15321
|
-
delivered: 0,
|
|
15322
|
-
bounced: 0,
|
|
15323
|
-
hardBounced: 0,
|
|
15324
|
-
softBounced: 0,
|
|
15325
|
-
complained: 0,
|
|
15326
|
-
failedToSend: 0
|
|
15327
|
-
};
|
|
15328
|
-
var ZERO_RATES = {
|
|
15329
|
-
bounceRate: 0,
|
|
15330
|
-
hardBounceRate: 0,
|
|
15331
|
-
complaintRate: 0,
|
|
15332
|
-
failureRate: 0
|
|
15333
|
-
};
|
|
15334
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15335
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15336
|
-
const writes = [];
|
|
15337
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15338
|
-
if (dims) {
|
|
15339
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15340
|
-
if (domain) {
|
|
15341
|
-
const id = healthBucketId(domain, dims.kind);
|
|
15342
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15343
|
-
}
|
|
15344
|
-
}
|
|
15345
|
-
await Promise.all(writes);
|
|
15346
|
-
}
|
|
15347
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15348
|
-
await ctx.collections.health.updateOne(
|
|
15349
|
-
{ _id },
|
|
15350
|
-
{
|
|
15351
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
15352
|
-
$setOnInsert: {
|
|
15353
|
-
_id,
|
|
15354
|
-
senderDomain,
|
|
15355
|
-
kind,
|
|
15356
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15357
|
-
windowDurationMs: windowMs,
|
|
15358
|
-
status: "healthy",
|
|
15359
|
-
trippedAt: null,
|
|
15360
|
-
trippedReason: null,
|
|
15361
|
-
manuallyResumedAt: null,
|
|
15362
|
-
rates: { ...ZERO_RATES }
|
|
15363
|
-
},
|
|
15364
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15365
|
-
},
|
|
15366
|
-
{ upsert: true }
|
|
15367
|
-
);
|
|
15368
|
-
}
|
|
15369
|
-
async function evaluateHealth(ctx) {
|
|
15370
|
-
const cb = ctx.config.circuitBreaker;
|
|
15371
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15372
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
15373
|
-
if (docs.length === 0) return;
|
|
15374
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15375
|
-
if (!hasAgg) {
|
|
15376
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15377
|
-
}
|
|
15378
|
-
for (const doc of docs) {
|
|
15379
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15380
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15381
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15382
|
-
await ctx.collections.health.updateOne(
|
|
15383
|
-
{ _id: doc._id },
|
|
15384
|
-
{
|
|
15385
|
-
$set: {
|
|
15386
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15387
|
-
windowDurationMs: windowMs,
|
|
15388
|
-
counters: { ...ZERO_COUNTERS },
|
|
15389
|
-
rates: { ...ZERO_RATES },
|
|
15390
|
-
status: "healthy",
|
|
15391
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
15392
|
-
}
|
|
15393
|
-
}
|
|
15394
|
-
);
|
|
15395
|
-
continue;
|
|
15396
|
-
}
|
|
15397
|
-
const c = doc.counters;
|
|
15398
|
-
const total = c.sent || 1;
|
|
15399
|
-
const rates = {
|
|
15400
|
-
bounceRate: c.bounced / total,
|
|
15401
|
-
hardBounceRate: c.hardBounced / total,
|
|
15402
|
-
complaintRate: c.complained / total,
|
|
15403
|
-
failureRate: c.failedToSend / total
|
|
15404
|
-
};
|
|
15405
|
-
await ctx.collections.health.updateOne(
|
|
15406
|
-
{ _id: doc._id },
|
|
15407
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15408
|
-
);
|
|
15409
|
-
if (isAgg) continue;
|
|
15410
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15411
|
-
if (doc.status === "tripped") continue;
|
|
15412
|
-
let trippedReason = null;
|
|
15413
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15414
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15415
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15416
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15417
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15418
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15419
|
-
}
|
|
15420
|
-
if (trippedReason) {
|
|
15421
|
-
const result = await ctx.collections.health.updateOne(
|
|
15422
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15423
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15424
|
-
);
|
|
15425
|
-
if (result.modifiedCount > 0) {
|
|
15426
|
-
if (ctx.audit) {
|
|
15427
|
-
try {
|
|
15428
|
-
await ctx.audit({
|
|
15429
|
-
actor: "system:circuit-breaker",
|
|
15430
|
-
action: "health.trip",
|
|
15431
|
-
resource: {
|
|
15432
|
-
collection: "mailer_health",
|
|
15433
|
-
id: String(doc._id),
|
|
15434
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15435
|
-
},
|
|
15436
|
-
diffSummary: trippedReason
|
|
15437
|
-
});
|
|
15438
|
-
} catch {
|
|
15439
|
-
}
|
|
15440
|
-
}
|
|
15441
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
15442
|
-
try {
|
|
15443
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
15444
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15445
|
-
rates
|
|
15446
|
-
});
|
|
15447
|
-
} catch {
|
|
15448
|
-
}
|
|
15449
|
-
}
|
|
15601
|
+
function decodeHtmlEntities(input) {
|
|
15602
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
15603
|
+
if (body[0] === "#") {
|
|
15604
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
15605
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
15606
|
+
try {
|
|
15607
|
+
return String.fromCodePoint(code);
|
|
15608
|
+
} catch {
|
|
15609
|
+
return match;
|
|
15450
15610
|
}
|
|
15451
|
-
continue;
|
|
15452
15611
|
}
|
|
15453
|
-
|
|
15454
|
-
|
|
15455
|
-
|
|
15456
|
-
|
|
15457
|
-
|
|
15458
|
-
|
|
15459
|
-
|
|
15460
|
-
|
|
15461
|
-
|
|
15462
|
-
|
|
15463
|
-
|
|
15464
|
-
|
|
15612
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
15613
|
+
return named ?? match;
|
|
15614
|
+
});
|
|
15615
|
+
}
|
|
15616
|
+
function shortHash(input) {
|
|
15617
|
+
return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
15618
|
+
}
|
|
15619
|
+
function makeHandlebars(extra) {
|
|
15620
|
+
const hb = Handlebars.create();
|
|
15621
|
+
hb.registerHelper("eq", (a, b) => a === b);
|
|
15622
|
+
hb.registerHelper("ne", (a, b) => a !== b);
|
|
15623
|
+
hb.registerHelper("gt", (a, b) => a > b);
|
|
15624
|
+
hb.registerHelper("lt", (a, b) => a < b);
|
|
15625
|
+
hb.registerHelper("gte", (a, b) => a >= b);
|
|
15626
|
+
hb.registerHelper("lte", (a, b) => a <= b);
|
|
15627
|
+
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
15628
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
15629
|
+
hb.registerHelper("not", (a) => !a);
|
|
15630
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
15631
|
+
if (!value) return "";
|
|
15632
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
15633
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
15634
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
15635
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
15636
|
+
return d.toISOString().slice(0, 10);
|
|
15637
|
+
});
|
|
15638
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
15639
|
+
const v = Number(n);
|
|
15640
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
15641
|
+
});
|
|
15642
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
15643
|
+
const v = Number(cents);
|
|
15644
|
+
if (!Number.isFinite(v)) return "";
|
|
15645
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
15646
|
+
});
|
|
15647
|
+
hb.registerHelper(
|
|
15648
|
+
"pluralize",
|
|
15649
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
15650
|
+
);
|
|
15651
|
+
if (extra) {
|
|
15652
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
15653
|
+
hb.registerHelper(name, fn);
|
|
15465
15654
|
}
|
|
15466
15655
|
}
|
|
15656
|
+
return hb;
|
|
15467
15657
|
}
|
|
15468
|
-
|
|
15469
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
15658
|
+
|
|
15659
|
+
// src/server/runner/send.ts
|
|
15660
|
+
init_vars();
|
|
15661
|
+
|
|
15662
|
+
// src/server/runner/suppression.ts
|
|
15663
|
+
var SCOPES_BY_KIND = {
|
|
15664
|
+
marketing: ["all", "marketing"],
|
|
15665
|
+
transactional: ["all", "transactional"]
|
|
15666
|
+
};
|
|
15667
|
+
async function isSuppressed(collections, email, kind) {
|
|
15668
|
+
const normalized = email.toLowerCase();
|
|
15669
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
15670
|
+
const byEmail = await collections.suppressions.findOne({
|
|
15671
|
+
email: normalized,
|
|
15672
|
+
scope: { $in: allowed },
|
|
15673
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
15674
|
+
});
|
|
15675
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
15676
|
+
const hashed = await collections.suppressions.findOne({
|
|
15677
|
+
emailHash: sha256Hex(normalized),
|
|
15678
|
+
scope: { $in: allowed }
|
|
15679
|
+
});
|
|
15680
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
15681
|
+
return { suppressed: false };
|
|
15472
15682
|
}
|
|
15473
15683
|
|
|
15474
15684
|
// src/server/runner/send.ts
|
|
@@ -15536,9 +15746,11 @@ async function handleSend(run, step2, contact, flow, ctx) {
|
|
|
15536
15746
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
15537
15747
|
}
|
|
15538
15748
|
async function dispatchSend(sendId, ctx) {
|
|
15539
|
-
const send = await ctx.collections.sends.
|
|
15749
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
15750
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
15751
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15752
|
+
);
|
|
15540
15753
|
if (!send) return;
|
|
15541
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
15542
15754
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
15543
15755
|
if (!template) {
|
|
15544
15756
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -15555,6 +15767,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15555
15767
|
if (send.kind === "marketing") {
|
|
15556
15768
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15557
15769
|
if (bucket?.status === "tripped") {
|
|
15770
|
+
await ctx.collections.sends.updateOne(
|
|
15771
|
+
{ _id: send._id },
|
|
15772
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15773
|
+
);
|
|
15558
15774
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15559
15775
|
return;
|
|
15560
15776
|
}
|
|
@@ -15803,7 +16019,7 @@ async function handleWait(run, step2, ctx) {
|
|
|
15803
16019
|
await ctx.queues.advance.add(
|
|
15804
16020
|
"advance",
|
|
15805
16021
|
{ flowRunId: String(run._id) },
|
|
15806
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
16022
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
15807
16023
|
);
|
|
15808
16024
|
}
|
|
15809
16025
|
async function handleCondition(run, step2, contact, ctx) {
|
|
@@ -15915,7 +16131,7 @@ async function handleWebhookStep(run, step2, ctx) {
|
|
|
15915
16131
|
await ctx.queues.advance.add(
|
|
15916
16132
|
"advance",
|
|
15917
16133
|
{ flowRunId: String(run._id) },
|
|
15918
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
16134
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
15919
16135
|
);
|
|
15920
16136
|
}
|
|
15921
16137
|
}
|
|
@@ -15944,7 +16160,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
15944
16160
|
{ flowRunId: String(run._id) },
|
|
15945
16161
|
{
|
|
15946
16162
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15947
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
16163
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15948
16164
|
}
|
|
15949
16165
|
);
|
|
15950
16166
|
}
|
|
@@ -15999,13 +16215,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
15999
16215
|
let arr = steps;
|
|
16000
16216
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
16001
16217
|
const parentIndex = branchPath[i];
|
|
16002
|
-
const
|
|
16218
|
+
const branchKey2 = branchPath[i + 1];
|
|
16003
16219
|
const parent = arr[parentIndex];
|
|
16004
16220
|
if (!parent || parent.type !== "branch") return null;
|
|
16005
|
-
arr =
|
|
16221
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
16006
16222
|
}
|
|
16007
16223
|
return arr[currentStepIndex] ?? null;
|
|
16008
16224
|
}
|
|
16225
|
+
function branchKey(run) {
|
|
16226
|
+
return run.currentBranchPath.join("_");
|
|
16227
|
+
}
|
|
16009
16228
|
function unitToMs(value, unit) {
|
|
16010
16229
|
const m = 6e4;
|
|
16011
16230
|
switch (unit) {
|
|
@@ -16032,6 +16251,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16032
16251
|
}
|
|
16033
16252
|
}
|
|
16034
16253
|
}
|
|
16254
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16035
16255
|
async function processScheduledBroadcasts(ctx) {
|
|
16036
16256
|
const now = /* @__PURE__ */ new Date();
|
|
16037
16257
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -16042,15 +16262,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16042
16262
|
{ returnDocument: "after" }
|
|
16043
16263
|
);
|
|
16044
16264
|
if (!claimed) continue;
|
|
16045
|
-
|
|
16046
|
-
|
|
16047
|
-
|
|
16048
|
-
|
|
16049
|
-
|
|
16050
|
-
|
|
16051
|
-
|
|
16052
|
-
|
|
16265
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
16266
|
+
}
|
|
16267
|
+
}
|
|
16268
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
16269
|
+
if (ctx.config.queue.driver === "noop") {
|
|
16270
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16271
|
+
return;
|
|
16272
|
+
}
|
|
16273
|
+
await ctx.queues.advance.add(
|
|
16274
|
+
"advance",
|
|
16275
|
+
{ broadcastId: String(broadcast._id) },
|
|
16276
|
+
{
|
|
16277
|
+
attempts: 3,
|
|
16278
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
16279
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
16053
16280
|
}
|
|
16281
|
+
);
|
|
16282
|
+
}
|
|
16283
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
16284
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
16285
|
+
if (!broadcast) return;
|
|
16286
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16287
|
+
}
|
|
16288
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
16289
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16290
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16291
|
+
for (const b of stalled) {
|
|
16292
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16293
|
+
{ _id: b._id },
|
|
16294
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16295
|
+
);
|
|
16296
|
+
await startBroadcastDispatch(b, ctx);
|
|
16297
|
+
}
|
|
16298
|
+
}
|
|
16299
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
16300
|
+
try {
|
|
16301
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
16302
|
+
} catch (err) {
|
|
16303
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16304
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16305
|
+
{ _id: broadcast._id },
|
|
16306
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16307
|
+
);
|
|
16054
16308
|
}
|
|
16055
16309
|
}
|
|
16056
16310
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -16071,11 +16325,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16071
16325
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
16072
16326
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
16073
16327
|
for (; ; ) {
|
|
16328
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16329
|
+
{ _id: broadcast._id },
|
|
16330
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16331
|
+
);
|
|
16074
16332
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
16075
16333
|
if (page.contacts.length === 0) break;
|
|
16076
16334
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
16077
16335
|
if (eligible.length > 0) {
|
|
16078
16336
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16337
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16338
|
+
{ _id: broadcast._id },
|
|
16339
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16340
|
+
);
|
|
16079
16341
|
await sleep(2e3);
|
|
16080
16342
|
}
|
|
16081
16343
|
const sendDocs = await Promise.all(
|
|
@@ -16198,7 +16460,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16198
16460
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
16199
16461
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
16200
16462
|
if (respectTimezone && contact.timezone) {
|
|
16201
|
-
|
|
16463
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
16464
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
16202
16465
|
}
|
|
16203
16466
|
const doc = {
|
|
16204
16467
|
_id: sendId,
|
|
@@ -16237,7 +16500,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16237
16500
|
};
|
|
16238
16501
|
return { doc, delayMs };
|
|
16239
16502
|
}
|
|
16240
|
-
function
|
|
16503
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16504
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16241
16505
|
try {
|
|
16242
16506
|
const scheduled = new Date(scheduledMs);
|
|
16243
16507
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -16250,7 +16514,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
16250
16514
|
const utcMs = parse(utc);
|
|
16251
16515
|
const localMs = parse(local);
|
|
16252
16516
|
const offsetMs = utcMs - localMs;
|
|
16253
|
-
return offsetMs;
|
|
16517
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
16254
16518
|
} catch {
|
|
16255
16519
|
return 0;
|
|
16256
16520
|
}
|
|
@@ -16925,6 +17189,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
|
|
|
16925
17189
|
|
|
16926
17190
|
// src/server/runner/tick.ts
|
|
16927
17191
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
17192
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16928
17193
|
async function runTick(ctx) {
|
|
16929
17194
|
try {
|
|
16930
17195
|
await ctx.collections.health.updateOne(
|
|
@@ -16965,12 +17230,18 @@ async function runTick(ctx) {
|
|
|
16965
17230
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
16966
17231
|
console.error("mailery: broadcast dispatch failed", err);
|
|
16967
17232
|
});
|
|
17233
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17234
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17235
|
+
});
|
|
16968
17236
|
await evaluateHealth(ctx).catch((err) => {
|
|
16969
17237
|
console.error("mailery: health evaluation failed", err);
|
|
16970
17238
|
});
|
|
16971
17239
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
16972
17240
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
16973
17241
|
});
|
|
17242
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
17243
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
17244
|
+
});
|
|
16974
17245
|
await Promise.all([
|
|
16975
17246
|
runDnsblChecks(ctx).catch((err) => {
|
|
16976
17247
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -17041,140 +17312,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
17041
17312
|
await processScheduledBroadcasts(ctx);
|
|
17042
17313
|
}
|
|
17043
17314
|
|
|
17044
|
-
// src/server/runner/webhook.ts
|
|
17045
|
-
function dimsFromSend(send) {
|
|
17046
|
-
if (!send) return null;
|
|
17047
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
17048
|
-
}
|
|
17049
|
-
async function applyWebhookEvent(event, ctx) {
|
|
17050
|
-
const send = await ctx.collections.sends.findOne(
|
|
17051
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
17052
|
-
{ sort: { queuedAt: -1 } }
|
|
17053
|
-
);
|
|
17054
|
-
switch (event.type) {
|
|
17055
|
-
case "delivered":
|
|
17056
|
-
if (send) {
|
|
17057
|
-
await ctx.collections.sends.updateOne(
|
|
17058
|
-
{ _id: send._id },
|
|
17059
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
17060
|
-
);
|
|
17061
|
-
}
|
|
17062
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
17063
|
-
break;
|
|
17064
|
-
case "open":
|
|
17065
|
-
if (send) {
|
|
17066
|
-
await ctx.collections.sends.updateOne(
|
|
17067
|
-
{ _id: send._id },
|
|
17068
|
-
{
|
|
17069
|
-
$set: {
|
|
17070
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
17071
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
17072
|
-
},
|
|
17073
|
-
$inc: { openCount: 1 }
|
|
17074
|
-
}
|
|
17075
|
-
);
|
|
17076
|
-
}
|
|
17077
|
-
break;
|
|
17078
|
-
case "click":
|
|
17079
|
-
if (send) {
|
|
17080
|
-
await ctx.collections.sends.updateOne(
|
|
17081
|
-
{ _id: send._id },
|
|
17082
|
-
{
|
|
17083
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
17084
|
-
$inc: { clickCount: 1 },
|
|
17085
|
-
$push: {
|
|
17086
|
-
clickedLinks: {
|
|
17087
|
-
url: event.details.clickedUrl ?? "",
|
|
17088
|
-
linkId: "",
|
|
17089
|
-
clickedAt: event.occurredAt
|
|
17090
|
-
}
|
|
17091
|
-
}
|
|
17092
|
-
}
|
|
17093
|
-
);
|
|
17094
|
-
}
|
|
17095
|
-
break;
|
|
17096
|
-
case "bounce": {
|
|
17097
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
17098
|
-
if (send) {
|
|
17099
|
-
await ctx.collections.sends.updateOne(
|
|
17100
|
-
{ _id: send._id },
|
|
17101
|
-
{
|
|
17102
|
-
$set: {
|
|
17103
|
-
status: "bounced",
|
|
17104
|
-
bounceType,
|
|
17105
|
-
bounceReason: event.details.bounceReason ?? null
|
|
17106
|
-
}
|
|
17107
|
-
}
|
|
17108
|
-
);
|
|
17109
|
-
}
|
|
17110
|
-
if (bounceType === "hard") {
|
|
17111
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
17112
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17113
|
-
{ emailAtSubscribe: event.email },
|
|
17114
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17115
|
-
);
|
|
17116
|
-
}
|
|
17117
|
-
{
|
|
17118
|
-
const dims = dimsFromSend(send);
|
|
17119
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
17120
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
17121
|
-
}
|
|
17122
|
-
break;
|
|
17123
|
-
}
|
|
17124
|
-
case "complaint":
|
|
17125
|
-
case "spam_report":
|
|
17126
|
-
if (send) {
|
|
17127
|
-
await ctx.collections.sends.updateOne(
|
|
17128
|
-
{ _id: send._id },
|
|
17129
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
17130
|
-
);
|
|
17131
|
-
}
|
|
17132
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
17133
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17134
|
-
{ emailAtSubscribe: event.email },
|
|
17135
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17136
|
-
);
|
|
17137
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
17138
|
-
break;
|
|
17139
|
-
case "unsubscribe":
|
|
17140
|
-
if (send) {
|
|
17141
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
17142
|
-
}
|
|
17143
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
17144
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17145
|
-
{ emailAtSubscribe: event.email },
|
|
17146
|
-
{
|
|
17147
|
-
$set: {
|
|
17148
|
-
status: "unsubscribed",
|
|
17149
|
-
unsubscribedAt: event.occurredAt,
|
|
17150
|
-
unsubscribeReason: "user_request",
|
|
17151
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
17152
|
-
}
|
|
17153
|
-
}
|
|
17154
|
-
);
|
|
17155
|
-
break;
|
|
17156
|
-
}
|
|
17157
|
-
}
|
|
17158
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
17159
|
-
const normalized = email.toLowerCase();
|
|
17160
|
-
await ctx.collections.suppressions.updateOne(
|
|
17161
|
-
{ email: normalized, scope },
|
|
17162
|
-
{
|
|
17163
|
-
$setOnInsert: {
|
|
17164
|
-
email: normalized,
|
|
17165
|
-
emailHash: sha256Hex(normalized),
|
|
17166
|
-
scope,
|
|
17167
|
-
reason,
|
|
17168
|
-
source: "provider_webhook",
|
|
17169
|
-
notes: null,
|
|
17170
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
17171
|
-
expiresAt: null
|
|
17172
|
-
}
|
|
17173
|
-
},
|
|
17174
|
-
{ upsert: true }
|
|
17175
|
-
);
|
|
17176
|
-
}
|
|
17177
|
-
|
|
17178
17315
|
// src/server/mailer.ts
|
|
17179
17316
|
var Mailer = class _Mailer {
|
|
17180
17317
|
db;
|
|
@@ -17639,41 +17776,48 @@ var Mailer = class _Mailer {
|
|
|
17639
17776
|
if (existing) return { sendId: String(existing._id) };
|
|
17640
17777
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
17641
17778
|
const sendId = new ObjectId();
|
|
17642
|
-
|
|
17643
|
-
|
|
17644
|
-
|
|
17645
|
-
|
|
17646
|
-
|
|
17647
|
-
|
|
17648
|
-
|
|
17649
|
-
|
|
17650
|
-
|
|
17651
|
-
|
|
17652
|
-
|
|
17653
|
-
|
|
17654
|
-
|
|
17655
|
-
|
|
17656
|
-
|
|
17657
|
-
|
|
17658
|
-
|
|
17659
|
-
|
|
17660
|
-
|
|
17661
|
-
|
|
17662
|
-
|
|
17663
|
-
|
|
17664
|
-
|
|
17665
|
-
|
|
17666
|
-
|
|
17667
|
-
|
|
17668
|
-
|
|
17669
|
-
|
|
17670
|
-
|
|
17671
|
-
|
|
17672
|
-
|
|
17673
|
-
|
|
17674
|
-
|
|
17675
|
-
|
|
17676
|
-
|
|
17779
|
+
try {
|
|
17780
|
+
await this.collections.sends.insertOne({
|
|
17781
|
+
_id: sendId,
|
|
17782
|
+
dedupeKey,
|
|
17783
|
+
externalId: parsed.externalId,
|
|
17784
|
+
emailAtSend: contact.email,
|
|
17785
|
+
templateId: template._id,
|
|
17786
|
+
templateSlug: template.slug,
|
|
17787
|
+
flowRunId: null,
|
|
17788
|
+
broadcastId: null,
|
|
17789
|
+
manualSendBy: "sendOneOff",
|
|
17790
|
+
kind: template.kind,
|
|
17791
|
+
provider: providerName,
|
|
17792
|
+
providerMessageId: null,
|
|
17793
|
+
fromName: template.fromName,
|
|
17794
|
+
fromEmail: template.fromEmail,
|
|
17795
|
+
subject: template.subject,
|
|
17796
|
+
bodyHash: "",
|
|
17797
|
+
status: "queued",
|
|
17798
|
+
errorMessage: null,
|
|
17799
|
+
bounceType: null,
|
|
17800
|
+
bounceReason: null,
|
|
17801
|
+
links: [],
|
|
17802
|
+
vars: parsed.vars ?? {},
|
|
17803
|
+
openedAt: null,
|
|
17804
|
+
openCount: 0,
|
|
17805
|
+
firstClickAt: null,
|
|
17806
|
+
clickCount: 0,
|
|
17807
|
+
clickedLinks: [],
|
|
17808
|
+
unsubscribedAt: null,
|
|
17809
|
+
complainedAt: null,
|
|
17810
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
17811
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17812
|
+
sentAt: null,
|
|
17813
|
+
deliveredAt: null
|
|
17814
|
+
});
|
|
17815
|
+
} catch (err) {
|
|
17816
|
+
if (err?.code !== 11e3) throw err;
|
|
17817
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
17818
|
+
if (winner) return { sendId: String(winner._id) };
|
|
17819
|
+
throw err;
|
|
17820
|
+
}
|
|
17677
17821
|
await this.queues.send.add(
|
|
17678
17822
|
"send",
|
|
17679
17823
|
{ sendId: String(sendId) },
|
|
@@ -17717,7 +17861,12 @@ var Mailer = class _Mailer {
|
|
|
17717
17861
|
await runTick(this.runnerContext);
|
|
17718
17862
|
},
|
|
17719
17863
|
advance: async (data) => {
|
|
17720
|
-
if (
|
|
17864
|
+
if (data.broadcastId) {
|
|
17865
|
+
if (!ObjectId.isValid(data.broadcastId)) return;
|
|
17866
|
+
await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
|
|
17867
|
+
return;
|
|
17868
|
+
}
|
|
17869
|
+
if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
|
|
17721
17870
|
await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
|
|
17722
17871
|
},
|
|
17723
17872
|
send: async (data) => {
|
|
@@ -17733,27 +17882,7 @@ var Mailer = class _Mailer {
|
|
|
17733
17882
|
}
|
|
17734
17883
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
17735
17884
|
async processWebhookBacklog() {
|
|
17736
|
-
|
|
17737
|
-
for (const evt of batch) {
|
|
17738
|
-
try {
|
|
17739
|
-
const normalized = evt.raw?.normalized;
|
|
17740
|
-
const details = normalized?.details ?? {};
|
|
17741
|
-
await applyWebhookEvent(
|
|
17742
|
-
{
|
|
17743
|
-
type: evt.normalizedType,
|
|
17744
|
-
providerEventId: evt.providerEventId,
|
|
17745
|
-
providerMessageId: evt.providerMessageId,
|
|
17746
|
-
email: evt.email,
|
|
17747
|
-
occurredAt: evt.occurredAt,
|
|
17748
|
-
details
|
|
17749
|
-
},
|
|
17750
|
-
this.runnerContext
|
|
17751
|
-
);
|
|
17752
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
17753
|
-
} catch (err) {
|
|
17754
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
17755
|
-
}
|
|
17756
|
-
}
|
|
17885
|
+
await processWebhookBacklog(this.runnerContext);
|
|
17757
17886
|
}
|
|
17758
17887
|
async stop() {
|
|
17759
17888
|
await this.queueDriver.close();
|