mailery 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +803 -661
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +803 -661
- package/dist/index.js.map +1 -1
- package/dist/{null-BCxlHRJ0.d.cts → null-BRSHAxvg.d.cts} +14 -0
- package/dist/{null-BCxlHRJ0.d.ts → null-BRSHAxvg.d.ts} +14 -0
- package/dist/testing.cjs +701 -559
- 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 +701 -559
- 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 },
|
|
@@ -14630,7 +14636,7 @@ var BullDriver = class _BullDriver {
|
|
|
14630
14636
|
bullQueues;
|
|
14631
14637
|
workers = null;
|
|
14632
14638
|
bull;
|
|
14633
|
-
static async create(redisConfig) {
|
|
14639
|
+
static async create(redisConfig, prefix) {
|
|
14634
14640
|
let bull;
|
|
14635
14641
|
try {
|
|
14636
14642
|
bull = await import('bullmq');
|
|
@@ -14639,13 +14645,27 @@ var BullDriver = class _BullDriver {
|
|
|
14639
14645
|
"mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
|
|
14640
14646
|
);
|
|
14641
14647
|
}
|
|
14648
|
+
if (prefix?.includes(":")) {
|
|
14649
|
+
throw new Error(
|
|
14650
|
+
`mailery: queue prefix "${prefix}" must not contain ':' \u2014 BullMQ uses it as the Redis key separator.`
|
|
14651
|
+
);
|
|
14652
|
+
}
|
|
14642
14653
|
const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
|
|
14643
|
-
return new _BullDriver(bull, redis);
|
|
14654
|
+
return new _BullDriver(bull, redis, prefix);
|
|
14644
14655
|
}
|
|
14645
|
-
|
|
14656
|
+
prefix;
|
|
14657
|
+
constructor(bull, redis, prefix) {
|
|
14646
14658
|
this.bull = bull;
|
|
14647
14659
|
this.redis = redis;
|
|
14648
|
-
|
|
14660
|
+
this.prefix = prefix;
|
|
14661
|
+
const opts = {
|
|
14662
|
+
connection: redis,
|
|
14663
|
+
prefix,
|
|
14664
|
+
defaultJobOptions: {
|
|
14665
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
14666
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
14667
|
+
}
|
|
14668
|
+
};
|
|
14649
14669
|
this.bullQueues = {
|
|
14650
14670
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
14651
14671
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -14663,12 +14683,16 @@ var BullDriver = class _BullDriver {
|
|
|
14663
14683
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
14664
14684
|
"mailer-tick-repeat",
|
|
14665
14685
|
{ every: intervalSeconds * 1e3 },
|
|
14666
|
-
{
|
|
14686
|
+
{
|
|
14687
|
+
name: "tick",
|
|
14688
|
+
data: {},
|
|
14689
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
14690
|
+
}
|
|
14667
14691
|
);
|
|
14668
14692
|
}
|
|
14669
14693
|
async startWorkers(opts) {
|
|
14670
14694
|
if (this.workers) return;
|
|
14671
|
-
const base = { connection: this.redis };
|
|
14695
|
+
const base = { connection: this.redis, prefix: this.prefix };
|
|
14672
14696
|
const { Worker } = this.bull;
|
|
14673
14697
|
const tick = new Worker(
|
|
14674
14698
|
QUEUE_NAMES.tick,
|
|
@@ -14773,9 +14797,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14773
14797
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
14774
14798
|
);
|
|
14775
14799
|
}
|
|
14800
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
14776
14801
|
const backend = new backendMod.MongoBackend({
|
|
14777
14802
|
mongo: opts.db,
|
|
14778
|
-
collection:
|
|
14803
|
+
collection: collectionName
|
|
14779
14804
|
});
|
|
14780
14805
|
const agenda = new agendaMod.Agenda({
|
|
14781
14806
|
backend,
|
|
@@ -14784,13 +14809,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14784
14809
|
maxConcurrency: 50,
|
|
14785
14810
|
defaultConcurrency: 5
|
|
14786
14811
|
});
|
|
14787
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
14812
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
14788
14813
|
}
|
|
14789
14814
|
db;
|
|
14790
|
-
|
|
14815
|
+
collName;
|
|
14816
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
14791
14817
|
this.agenda = agenda;
|
|
14792
14818
|
this.agendaMod = agendaMod;
|
|
14793
14819
|
this.db = db;
|
|
14820
|
+
this.collName = collectionName;
|
|
14794
14821
|
this.queues = {
|
|
14795
14822
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
14796
14823
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -14823,10 +14850,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14823
14850
|
}
|
|
14824
14851
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
14825
14852
|
jobsCollection() {
|
|
14826
|
-
return this.db.collection(this.
|
|
14827
|
-
}
|
|
14828
|
-
collectionName() {
|
|
14829
|
-
return "_mailerJobs";
|
|
14853
|
+
return this.db.collection(this.collName);
|
|
14830
14854
|
}
|
|
14831
14855
|
async findPending(name, jobId) {
|
|
14832
14856
|
return this.jobsCollection().findOne({
|
|
@@ -14836,12 +14860,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14836
14860
|
});
|
|
14837
14861
|
}
|
|
14838
14862
|
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
14863
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
14846
14864
|
}
|
|
14847
14865
|
async startWorkers(opts) {
|
|
@@ -14926,7 +14944,7 @@ var NoopDriver = class {
|
|
|
14926
14944
|
async function createQueueDriver(config, fallbackDb) {
|
|
14927
14945
|
switch (config.driver) {
|
|
14928
14946
|
case "bull":
|
|
14929
|
-
return BullDriver.create(config.redis);
|
|
14947
|
+
return BullDriver.create(config.redis, config.prefix);
|
|
14930
14948
|
case "agenda":
|
|
14931
14949
|
return AgendaDriver.create({
|
|
14932
14950
|
db: config.db ?? fallbackDb,
|
|
@@ -14944,6 +14962,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
14944
14962
|
|
|
14945
14963
|
// src/server/runner/triggers.ts
|
|
14946
14964
|
var BATCH_SIZE = 1e3;
|
|
14965
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
14947
14966
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
14948
14967
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
14949
14968
|
for (const flow of flows) {
|
|
@@ -14954,16 +14973,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
14954
14973
|
const eventName = flow.trigger.eventName;
|
|
14955
14974
|
if (!eventName) return;
|
|
14956
14975
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
14957
|
-
const
|
|
14976
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
14977
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
14958
14978
|
if (events.length === 0) return;
|
|
14959
14979
|
for (const event of events) {
|
|
14960
14980
|
await tryEnterFlow(flow, event, ctx);
|
|
14961
14981
|
}
|
|
14962
|
-
const
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
14982
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
14983
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
14984
|
+
await ctx.collections.flows.updateOne(
|
|
14985
|
+
{ _id: flow._id },
|
|
14986
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
14987
|
+
);
|
|
14988
|
+
}
|
|
14967
14989
|
}
|
|
14968
14990
|
async function tryEnterFlow(flow, event, ctx) {
|
|
14969
14991
|
if (flow.trigger.once) {
|
|
@@ -14975,117 +14997,453 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14975
14997
|
}
|
|
14976
14998
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
14977
14999
|
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
|
-
|
|
15000
|
+
let result;
|
|
15001
|
+
try {
|
|
15002
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
15003
|
+
externalId: event.externalId,
|
|
15004
|
+
flowId: flow._id,
|
|
15005
|
+
flowSlug: flow.slug,
|
|
15006
|
+
flowVersion: flow.version,
|
|
15007
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
15008
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
15009
|
+
triggerDedupeKey: event.dedupeKey,
|
|
15010
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
15011
|
+
status: "active",
|
|
15012
|
+
currentStepIndex: 0,
|
|
15013
|
+
currentBranchPath: [],
|
|
15014
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
15015
|
+
attemptsForCurrentStep: 0,
|
|
15016
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
15017
|
+
exitedAt: null,
|
|
15018
|
+
exitReason: null,
|
|
15019
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
15020
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15021
|
+
});
|
|
15022
|
+
} catch (err) {
|
|
15023
|
+
if (err?.code !== 11e3) throw err;
|
|
15024
|
+
return;
|
|
15025
|
+
}
|
|
14997
15026
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
14998
15027
|
}
|
|
14999
15028
|
|
|
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;
|
|
15029
|
+
// src/server/templates/sender-domain.ts
|
|
15030
|
+
function extractDomain(email) {
|
|
15031
|
+
if (typeof email !== "string") return null;
|
|
15032
|
+
const at = email.lastIndexOf("@");
|
|
15033
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15034
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15035
|
+
}
|
|
15036
|
+
|
|
15037
|
+
// src/server/runner/health.ts
|
|
15038
|
+
var ZERO_COUNTERS = {
|
|
15039
|
+
sent: 0,
|
|
15040
|
+
delivered: 0,
|
|
15041
|
+
bounced: 0,
|
|
15042
|
+
hardBounced: 0,
|
|
15043
|
+
softBounced: 0,
|
|
15044
|
+
complained: 0,
|
|
15045
|
+
failedToSend: 0
|
|
15046
|
+
};
|
|
15047
|
+
var ZERO_RATES = {
|
|
15048
|
+
bounceRate: 0,
|
|
15049
|
+
hardBounceRate: 0,
|
|
15050
|
+
complaintRate: 0,
|
|
15051
|
+
failureRate: 0
|
|
15052
|
+
};
|
|
15053
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15054
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15055
|
+
const writes = [];
|
|
15056
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15057
|
+
if (dims) {
|
|
15058
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15059
|
+
if (domain) {
|
|
15060
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15061
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15037
15062
|
}
|
|
15038
|
-
return false;
|
|
15039
15063
|
}
|
|
15040
|
-
|
|
15041
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
15042
|
-
}
|
|
15043
|
-
return false;
|
|
15064
|
+
await Promise.all(writes);
|
|
15044
15065
|
}
|
|
15045
|
-
async function
|
|
15046
|
-
|
|
15047
|
-
|
|
15048
|
-
|
|
15049
|
-
|
|
15050
|
-
|
|
15066
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15067
|
+
await ctx.collections.health.updateOne(
|
|
15068
|
+
{ _id },
|
|
15069
|
+
{
|
|
15070
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
15071
|
+
$setOnInsert: {
|
|
15072
|
+
_id,
|
|
15073
|
+
senderDomain,
|
|
15074
|
+
kind,
|
|
15075
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15076
|
+
windowDurationMs: windowMs,
|
|
15077
|
+
status: "healthy",
|
|
15078
|
+
trippedAt: null,
|
|
15079
|
+
trippedReason: null,
|
|
15080
|
+
manuallyResumedAt: null,
|
|
15081
|
+
rates: { ...ZERO_RATES }
|
|
15082
|
+
},
|
|
15083
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15084
|
+
},
|
|
15085
|
+
{ upsert: true }
|
|
15086
|
+
);
|
|
15051
15087
|
}
|
|
15052
|
-
async function
|
|
15053
|
-
const
|
|
15054
|
-
|
|
15055
|
-
|
|
15056
|
-
if (
|
|
15057
|
-
const
|
|
15058
|
-
if (
|
|
15059
|
-
|
|
15060
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
15088
|
+
async function evaluateHealth(ctx) {
|
|
15089
|
+
const cb = ctx.config.circuitBreaker;
|
|
15090
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15091
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15092
|
+
if (docs.length === 0) return;
|
|
15093
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15094
|
+
if (!hasAgg) {
|
|
15095
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15061
15096
|
}
|
|
15062
|
-
const
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
if (
|
|
15066
|
-
|
|
15097
|
+
for (const doc of docs) {
|
|
15098
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15099
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15100
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15101
|
+
await ctx.collections.health.updateOne(
|
|
15102
|
+
{ _id: doc._id },
|
|
15103
|
+
{
|
|
15104
|
+
$set: {
|
|
15105
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15106
|
+
windowDurationMs: windowMs,
|
|
15107
|
+
counters: { ...ZERO_COUNTERS },
|
|
15108
|
+
rates: { ...ZERO_RATES },
|
|
15109
|
+
status: "healthy",
|
|
15110
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15111
|
+
}
|
|
15112
|
+
}
|
|
15113
|
+
);
|
|
15067
15114
|
continue;
|
|
15068
15115
|
}
|
|
15069
|
-
const
|
|
15070
|
-
|
|
15116
|
+
const c = doc.counters;
|
|
15117
|
+
const total = c.sent || 1;
|
|
15118
|
+
const rates = {
|
|
15119
|
+
bounceRate: c.bounced / total,
|
|
15120
|
+
hardBounceRate: c.hardBounced / total,
|
|
15121
|
+
complaintRate: c.complained / total,
|
|
15122
|
+
failureRate: c.failedToSend / total
|
|
15123
|
+
};
|
|
15124
|
+
await ctx.collections.health.updateOne(
|
|
15125
|
+
{ _id: doc._id },
|
|
15126
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15127
|
+
);
|
|
15128
|
+
if (isAgg) continue;
|
|
15129
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15130
|
+
if (doc.status === "tripped") continue;
|
|
15131
|
+
let trippedReason = null;
|
|
15132
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15133
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15134
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15135
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15136
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15137
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15138
|
+
}
|
|
15139
|
+
if (trippedReason) {
|
|
15140
|
+
const result = await ctx.collections.health.updateOne(
|
|
15141
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15142
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15143
|
+
);
|
|
15144
|
+
if (result.modifiedCount > 0) {
|
|
15145
|
+
if (ctx.audit) {
|
|
15146
|
+
try {
|
|
15147
|
+
await ctx.audit({
|
|
15148
|
+
actor: "system:circuit-breaker",
|
|
15149
|
+
action: "health.trip",
|
|
15150
|
+
resource: {
|
|
15151
|
+
collection: "mailer_health",
|
|
15152
|
+
id: String(doc._id),
|
|
15153
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15154
|
+
},
|
|
15155
|
+
diffSummary: trippedReason
|
|
15156
|
+
});
|
|
15157
|
+
} catch {
|
|
15158
|
+
}
|
|
15159
|
+
}
|
|
15160
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15161
|
+
try {
|
|
15162
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15163
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15164
|
+
rates
|
|
15165
|
+
});
|
|
15166
|
+
} catch {
|
|
15167
|
+
}
|
|
15168
|
+
}
|
|
15169
|
+
}
|
|
15170
|
+
continue;
|
|
15171
|
+
}
|
|
15172
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15173
|
+
if (doc.status !== "degraded") {
|
|
15174
|
+
await ctx.collections.health.updateOne(
|
|
15175
|
+
{ _id: doc._id },
|
|
15176
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15177
|
+
);
|
|
15178
|
+
}
|
|
15179
|
+
} else if (doc.status === "degraded") {
|
|
15180
|
+
await ctx.collections.health.updateOne(
|
|
15181
|
+
{ _id: doc._id },
|
|
15182
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15183
|
+
);
|
|
15184
|
+
}
|
|
15071
15185
|
}
|
|
15072
|
-
return n;
|
|
15073
15186
|
}
|
|
15074
|
-
function
|
|
15075
|
-
const
|
|
15076
|
-
|
|
15077
|
-
|
|
15078
|
-
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15079
|
-
}
|
|
15080
|
-
return null;
|
|
15187
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15188
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15189
|
+
const id = healthBucketId(domain, kind);
|
|
15190
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15081
15191
|
}
|
|
15082
15192
|
|
|
15083
|
-
// src/server/runner/
|
|
15084
|
-
|
|
15085
|
-
|
|
15086
|
-
|
|
15087
|
-
|
|
15088
|
-
|
|
15193
|
+
// src/server/runner/webhook.ts
|
|
15194
|
+
function dimsFromSend(send) {
|
|
15195
|
+
if (!send) return null;
|
|
15196
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
15197
|
+
}
|
|
15198
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
15199
|
+
const filter = { processed: false };
|
|
15200
|
+
if (opts.olderThanMs) {
|
|
15201
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
15202
|
+
}
|
|
15203
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
15204
|
+
for (const evt of batch) {
|
|
15205
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
15206
|
+
{ _id: evt._id, processed: false },
|
|
15207
|
+
{ $set: { processed: true } }
|
|
15208
|
+
);
|
|
15209
|
+
if (!claimed) continue;
|
|
15210
|
+
try {
|
|
15211
|
+
const normalized = evt.raw?.normalized;
|
|
15212
|
+
const details = normalized?.details ?? {};
|
|
15213
|
+
await applyWebhookEvent(
|
|
15214
|
+
{
|
|
15215
|
+
type: evt.normalizedType,
|
|
15216
|
+
providerEventId: evt.providerEventId,
|
|
15217
|
+
providerMessageId: evt.providerMessageId,
|
|
15218
|
+
email: evt.email,
|
|
15219
|
+
occurredAt: evt.occurredAt,
|
|
15220
|
+
details
|
|
15221
|
+
},
|
|
15222
|
+
ctx
|
|
15223
|
+
);
|
|
15224
|
+
} catch (err) {
|
|
15225
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
15226
|
+
}
|
|
15227
|
+
}
|
|
15228
|
+
}
|
|
15229
|
+
async function applyWebhookEvent(event, ctx) {
|
|
15230
|
+
const send = await ctx.collections.sends.findOne(
|
|
15231
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
15232
|
+
{ sort: { queuedAt: -1 } }
|
|
15233
|
+
);
|
|
15234
|
+
switch (event.type) {
|
|
15235
|
+
case "delivered":
|
|
15236
|
+
if (send) {
|
|
15237
|
+
await ctx.collections.sends.updateOne(
|
|
15238
|
+
{ _id: send._id },
|
|
15239
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
15240
|
+
);
|
|
15241
|
+
}
|
|
15242
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
15243
|
+
break;
|
|
15244
|
+
case "open":
|
|
15245
|
+
if (send) {
|
|
15246
|
+
await ctx.collections.sends.updateOne(
|
|
15247
|
+
{ _id: send._id },
|
|
15248
|
+
{
|
|
15249
|
+
$set: {
|
|
15250
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
15251
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
15252
|
+
},
|
|
15253
|
+
$inc: { openCount: 1 }
|
|
15254
|
+
}
|
|
15255
|
+
);
|
|
15256
|
+
}
|
|
15257
|
+
break;
|
|
15258
|
+
case "click":
|
|
15259
|
+
if (send) {
|
|
15260
|
+
await ctx.collections.sends.updateOne(
|
|
15261
|
+
{ _id: send._id },
|
|
15262
|
+
{
|
|
15263
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
15264
|
+
$inc: { clickCount: 1 },
|
|
15265
|
+
$push: {
|
|
15266
|
+
clickedLinks: {
|
|
15267
|
+
url: event.details.clickedUrl ?? "",
|
|
15268
|
+
linkId: "",
|
|
15269
|
+
clickedAt: event.occurredAt
|
|
15270
|
+
}
|
|
15271
|
+
}
|
|
15272
|
+
}
|
|
15273
|
+
);
|
|
15274
|
+
}
|
|
15275
|
+
break;
|
|
15276
|
+
case "bounce": {
|
|
15277
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
15278
|
+
if (send) {
|
|
15279
|
+
await ctx.collections.sends.updateOne(
|
|
15280
|
+
{ _id: send._id },
|
|
15281
|
+
{
|
|
15282
|
+
$set: {
|
|
15283
|
+
status: "bounced",
|
|
15284
|
+
bounceType,
|
|
15285
|
+
bounceReason: event.details.bounceReason ?? null
|
|
15286
|
+
}
|
|
15287
|
+
}
|
|
15288
|
+
);
|
|
15289
|
+
}
|
|
15290
|
+
if (bounceType === "hard") {
|
|
15291
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
15292
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15293
|
+
{ emailAtSubscribe: event.email },
|
|
15294
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15295
|
+
);
|
|
15296
|
+
}
|
|
15297
|
+
{
|
|
15298
|
+
const dims = dimsFromSend(send);
|
|
15299
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
15300
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
15301
|
+
}
|
|
15302
|
+
break;
|
|
15303
|
+
}
|
|
15304
|
+
case "complaint":
|
|
15305
|
+
case "spam_report":
|
|
15306
|
+
if (send) {
|
|
15307
|
+
await ctx.collections.sends.updateOne(
|
|
15308
|
+
{ _id: send._id },
|
|
15309
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
15310
|
+
);
|
|
15311
|
+
}
|
|
15312
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
15313
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15314
|
+
{ emailAtSubscribe: event.email },
|
|
15315
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15316
|
+
);
|
|
15317
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
15318
|
+
break;
|
|
15319
|
+
case "unsubscribe":
|
|
15320
|
+
if (send) {
|
|
15321
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
15322
|
+
}
|
|
15323
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
15324
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15325
|
+
{ emailAtSubscribe: event.email },
|
|
15326
|
+
{
|
|
15327
|
+
$set: {
|
|
15328
|
+
status: "unsubscribed",
|
|
15329
|
+
unsubscribedAt: event.occurredAt,
|
|
15330
|
+
unsubscribeReason: "user_request",
|
|
15331
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15332
|
+
}
|
|
15333
|
+
}
|
|
15334
|
+
);
|
|
15335
|
+
break;
|
|
15336
|
+
}
|
|
15337
|
+
}
|
|
15338
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
15339
|
+
const normalized = email.toLowerCase();
|
|
15340
|
+
await ctx.collections.suppressions.updateOne(
|
|
15341
|
+
{ email: normalized, scope },
|
|
15342
|
+
{
|
|
15343
|
+
$setOnInsert: {
|
|
15344
|
+
email: normalized,
|
|
15345
|
+
emailHash: sha256Hex(normalized),
|
|
15346
|
+
scope,
|
|
15347
|
+
reason,
|
|
15348
|
+
source: "provider_webhook",
|
|
15349
|
+
notes: null,
|
|
15350
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
15351
|
+
expiresAt: null
|
|
15352
|
+
}
|
|
15353
|
+
},
|
|
15354
|
+
{ upsert: true }
|
|
15355
|
+
);
|
|
15356
|
+
}
|
|
15357
|
+
|
|
15358
|
+
// src/server/runner/predicate.ts
|
|
15359
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
15360
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
15361
|
+
const p = predicate;
|
|
15362
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
15363
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
15364
|
+
if ("fieldEquals" in p) {
|
|
15365
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
15366
|
+
}
|
|
15367
|
+
if ("fieldExists" in p) {
|
|
15368
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
15369
|
+
}
|
|
15370
|
+
if ("subscriptionStatus" in p) {
|
|
15371
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
15372
|
+
return sub?.status === p.subscriptionStatus;
|
|
15373
|
+
}
|
|
15374
|
+
if ("hasFiredEvent" in p) {
|
|
15375
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
15376
|
+
}
|
|
15377
|
+
if ("notHasFiredEvent" in p) {
|
|
15378
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
15379
|
+
}
|
|
15380
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
15381
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
15382
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
15383
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
15384
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
15385
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
15386
|
+
if ("all" in p) {
|
|
15387
|
+
for (const sub of p.all) {
|
|
15388
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
15389
|
+
}
|
|
15390
|
+
return true;
|
|
15391
|
+
}
|
|
15392
|
+
if ("any" in p) {
|
|
15393
|
+
for (const sub of p.any) {
|
|
15394
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
15395
|
+
}
|
|
15396
|
+
return false;
|
|
15397
|
+
}
|
|
15398
|
+
if ("not" in p) {
|
|
15399
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
15400
|
+
}
|
|
15401
|
+
return false;
|
|
15402
|
+
}
|
|
15403
|
+
async function hasEvent(ctx, name, opts) {
|
|
15404
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
15405
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15406
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
15407
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
15408
|
+
return !!found;
|
|
15409
|
+
}
|
|
15410
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
15411
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
15412
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
15413
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
15414
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
15415
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15416
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
15417
|
+
if (!excludeBots) {
|
|
15418
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
15419
|
+
}
|
|
15420
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
15421
|
+
let n = 0;
|
|
15422
|
+
for (const s of docs) {
|
|
15423
|
+
if (kind === "opened") {
|
|
15424
|
+
n++;
|
|
15425
|
+
continue;
|
|
15426
|
+
}
|
|
15427
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
15428
|
+
if (hasHumanClick) n++;
|
|
15429
|
+
}
|
|
15430
|
+
return n;
|
|
15431
|
+
}
|
|
15432
|
+
function effectiveLowerBound(ctx, opts) {
|
|
15433
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
15434
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
15435
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
15436
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15437
|
+
}
|
|
15438
|
+
return null;
|
|
15439
|
+
}
|
|
15440
|
+
|
|
15441
|
+
// src/server/runner/delivery-window.ts
|
|
15442
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15443
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15444
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15445
|
+
let candidate = now;
|
|
15446
|
+
if (window2.timeOfDay) {
|
|
15089
15447
|
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15090
15448
|
const local = localParts(candidate, tz);
|
|
15091
15449
|
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
@@ -15220,7 +15578,8 @@ function applyTracking(html, opts) {
|
|
|
15220
15578
|
const seen = /* @__PURE__ */ new Map();
|
|
15221
15579
|
let out = html;
|
|
15222
15580
|
if (opts.trackClicks) {
|
|
15223
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
15581
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
15582
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
15224
15583
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
15225
15584
|
let linkId = seen.get(url);
|
|
15226
15585
|
if (!linkId) {
|
|
@@ -15252,236 +15611,95 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
15252
15611
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
15253
15612
|
return false;
|
|
15254
15613
|
}
|
|
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"]
|
|
15305
|
-
};
|
|
15306
|
-
async function isSuppressed(collections, email, kind) {
|
|
15307
|
-
const normalized = email.toLowerCase();
|
|
15308
|
-
const allowed = SCOPES_BY_KIND[kind];
|
|
15309
|
-
const byEmail = await collections.suppressions.findOne({
|
|
15310
|
-
email: normalized,
|
|
15311
|
-
scope: { $in: allowed },
|
|
15312
|
-
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
15313
|
-
});
|
|
15314
|
-
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
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
|
|
15614
|
+
var NAMED_ENTITIES = {
|
|
15615
|
+
amp: "&",
|
|
15616
|
+
lt: "<",
|
|
15617
|
+
gt: ">",
|
|
15618
|
+
quot: '"',
|
|
15619
|
+
apos: "'",
|
|
15620
|
+
nbsp: "\xA0"
|
|
15346
15621
|
};
|
|
15347
|
-
|
|
15348
|
-
|
|
15349
|
-
|
|
15350
|
-
|
|
15351
|
-
|
|
15352
|
-
|
|
15353
|
-
|
|
15354
|
-
|
|
15355
|
-
|
|
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
|
-
}
|
|
15622
|
+
function decodeHtmlEntities(input) {
|
|
15623
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
15624
|
+
if (body[0] === "#") {
|
|
15625
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
15626
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
15627
|
+
try {
|
|
15628
|
+
return String.fromCodePoint(code);
|
|
15629
|
+
} catch {
|
|
15630
|
+
return match;
|
|
15463
15631
|
}
|
|
15464
|
-
continue;
|
|
15465
15632
|
}
|
|
15466
|
-
|
|
15467
|
-
|
|
15468
|
-
|
|
15469
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
15472
|
-
|
|
15473
|
-
|
|
15474
|
-
|
|
15475
|
-
|
|
15476
|
-
|
|
15477
|
-
|
|
15633
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
15634
|
+
return named ?? match;
|
|
15635
|
+
});
|
|
15636
|
+
}
|
|
15637
|
+
function shortHash(input) {
|
|
15638
|
+
return crypto2__default.default.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
15639
|
+
}
|
|
15640
|
+
function makeHandlebars(extra) {
|
|
15641
|
+
const hb = Handlebars__default.default.create();
|
|
15642
|
+
hb.registerHelper("eq", (a, b) => a === b);
|
|
15643
|
+
hb.registerHelper("ne", (a, b) => a !== b);
|
|
15644
|
+
hb.registerHelper("gt", (a, b) => a > b);
|
|
15645
|
+
hb.registerHelper("lt", (a, b) => a < b);
|
|
15646
|
+
hb.registerHelper("gte", (a, b) => a >= b);
|
|
15647
|
+
hb.registerHelper("lte", (a, b) => a <= b);
|
|
15648
|
+
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
15649
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
15650
|
+
hb.registerHelper("not", (a) => !a);
|
|
15651
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
15652
|
+
if (!value) return "";
|
|
15653
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
15654
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
15655
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
15656
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
15657
|
+
return d.toISOString().slice(0, 10);
|
|
15658
|
+
});
|
|
15659
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
15660
|
+
const v = Number(n);
|
|
15661
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
15662
|
+
});
|
|
15663
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
15664
|
+
const v = Number(cents);
|
|
15665
|
+
if (!Number.isFinite(v)) return "";
|
|
15666
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
15667
|
+
});
|
|
15668
|
+
hb.registerHelper(
|
|
15669
|
+
"pluralize",
|
|
15670
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
15671
|
+
);
|
|
15672
|
+
if (extra) {
|
|
15673
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
15674
|
+
hb.registerHelper(name, fn);
|
|
15478
15675
|
}
|
|
15479
15676
|
}
|
|
15677
|
+
return hb;
|
|
15480
15678
|
}
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
|
|
15679
|
+
|
|
15680
|
+
// src/server/runner/send.ts
|
|
15681
|
+
init_vars();
|
|
15682
|
+
|
|
15683
|
+
// src/server/runner/suppression.ts
|
|
15684
|
+
var SCOPES_BY_KIND = {
|
|
15685
|
+
marketing: ["all", "marketing"],
|
|
15686
|
+
transactional: ["all", "transactional"]
|
|
15687
|
+
};
|
|
15688
|
+
async function isSuppressed(collections, email, kind) {
|
|
15689
|
+
const normalized = email.toLowerCase();
|
|
15690
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
15691
|
+
const byEmail = await collections.suppressions.findOne({
|
|
15692
|
+
email: normalized,
|
|
15693
|
+
scope: { $in: allowed },
|
|
15694
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
15695
|
+
});
|
|
15696
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
15697
|
+
const hashed = await collections.suppressions.findOne({
|
|
15698
|
+
emailHash: sha256Hex(normalized),
|
|
15699
|
+
scope: { $in: allowed }
|
|
15700
|
+
});
|
|
15701
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
15702
|
+
return { suppressed: false };
|
|
15485
15703
|
}
|
|
15486
15704
|
|
|
15487
15705
|
// src/server/runner/send.ts
|
|
@@ -15549,9 +15767,11 @@ async function handleSend(run, step2, contact, flow, ctx) {
|
|
|
15549
15767
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
15550
15768
|
}
|
|
15551
15769
|
async function dispatchSend(sendId, ctx) {
|
|
15552
|
-
const send = await ctx.collections.sends.
|
|
15770
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
15771
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
15772
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15773
|
+
);
|
|
15553
15774
|
if (!send) return;
|
|
15554
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
15555
15775
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
15556
15776
|
if (!template) {
|
|
15557
15777
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -15568,6 +15788,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15568
15788
|
if (send.kind === "marketing") {
|
|
15569
15789
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15570
15790
|
if (bucket?.status === "tripped") {
|
|
15791
|
+
await ctx.collections.sends.updateOne(
|
|
15792
|
+
{ _id: send._id },
|
|
15793
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15794
|
+
);
|
|
15571
15795
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15572
15796
|
return;
|
|
15573
15797
|
}
|
|
@@ -15816,7 +16040,7 @@ async function handleWait(run, step2, ctx) {
|
|
|
15816
16040
|
await ctx.queues.advance.add(
|
|
15817
16041
|
"advance",
|
|
15818
16042
|
{ flowRunId: String(run._id) },
|
|
15819
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
16043
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
15820
16044
|
);
|
|
15821
16045
|
}
|
|
15822
16046
|
async function handleCondition(run, step2, contact, ctx) {
|
|
@@ -15928,7 +16152,7 @@ async function handleWebhookStep(run, step2, ctx) {
|
|
|
15928
16152
|
await ctx.queues.advance.add(
|
|
15929
16153
|
"advance",
|
|
15930
16154
|
{ flowRunId: String(run._id) },
|
|
15931
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
16155
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
15932
16156
|
);
|
|
15933
16157
|
}
|
|
15934
16158
|
}
|
|
@@ -15957,7 +16181,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
15957
16181
|
{ flowRunId: String(run._id) },
|
|
15958
16182
|
{
|
|
15959
16183
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15960
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
16184
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15961
16185
|
}
|
|
15962
16186
|
);
|
|
15963
16187
|
}
|
|
@@ -16012,13 +16236,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
16012
16236
|
let arr = steps;
|
|
16013
16237
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
16014
16238
|
const parentIndex = branchPath[i];
|
|
16015
|
-
const
|
|
16239
|
+
const branchKey2 = branchPath[i + 1];
|
|
16016
16240
|
const parent = arr[parentIndex];
|
|
16017
16241
|
if (!parent || parent.type !== "branch") return null;
|
|
16018
|
-
arr =
|
|
16242
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
16019
16243
|
}
|
|
16020
16244
|
return arr[currentStepIndex] ?? null;
|
|
16021
16245
|
}
|
|
16246
|
+
function branchKey(run) {
|
|
16247
|
+
return run.currentBranchPath.join("_");
|
|
16248
|
+
}
|
|
16022
16249
|
function unitToMs(value, unit) {
|
|
16023
16250
|
const m = 6e4;
|
|
16024
16251
|
switch (unit) {
|
|
@@ -16045,6 +16272,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16045
16272
|
}
|
|
16046
16273
|
}
|
|
16047
16274
|
}
|
|
16275
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16048
16276
|
async function processScheduledBroadcasts(ctx) {
|
|
16049
16277
|
const now = /* @__PURE__ */ new Date();
|
|
16050
16278
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -16055,15 +16283,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16055
16283
|
{ returnDocument: "after" }
|
|
16056
16284
|
);
|
|
16057
16285
|
if (!claimed) continue;
|
|
16058
|
-
|
|
16059
|
-
|
|
16060
|
-
|
|
16061
|
-
|
|
16062
|
-
|
|
16063
|
-
|
|
16064
|
-
|
|
16065
|
-
|
|
16286
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
16287
|
+
}
|
|
16288
|
+
}
|
|
16289
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
16290
|
+
if (ctx.config.queue.driver === "noop") {
|
|
16291
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16292
|
+
return;
|
|
16293
|
+
}
|
|
16294
|
+
await ctx.queues.advance.add(
|
|
16295
|
+
"advance",
|
|
16296
|
+
{ broadcastId: String(broadcast._id) },
|
|
16297
|
+
{
|
|
16298
|
+
attempts: 3,
|
|
16299
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
16300
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
16066
16301
|
}
|
|
16302
|
+
);
|
|
16303
|
+
}
|
|
16304
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
16305
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
16306
|
+
if (!broadcast) return;
|
|
16307
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16308
|
+
}
|
|
16309
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
16310
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16311
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16312
|
+
for (const b of stalled) {
|
|
16313
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16314
|
+
{ _id: b._id },
|
|
16315
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16316
|
+
);
|
|
16317
|
+
await startBroadcastDispatch(b, ctx);
|
|
16318
|
+
}
|
|
16319
|
+
}
|
|
16320
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
16321
|
+
try {
|
|
16322
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
16323
|
+
} catch (err) {
|
|
16324
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16325
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16326
|
+
{ _id: broadcast._id },
|
|
16327
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16328
|
+
);
|
|
16067
16329
|
}
|
|
16068
16330
|
}
|
|
16069
16331
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -16084,11 +16346,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16084
16346
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
16085
16347
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
16086
16348
|
for (; ; ) {
|
|
16349
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16350
|
+
{ _id: broadcast._id },
|
|
16351
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16352
|
+
);
|
|
16087
16353
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
16088
16354
|
if (page.contacts.length === 0) break;
|
|
16089
16355
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
16090
16356
|
if (eligible.length > 0) {
|
|
16091
16357
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16358
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16359
|
+
{ _id: broadcast._id },
|
|
16360
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16361
|
+
);
|
|
16092
16362
|
await sleep(2e3);
|
|
16093
16363
|
}
|
|
16094
16364
|
const sendDocs = await Promise.all(
|
|
@@ -16211,7 +16481,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16211
16481
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
16212
16482
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
16213
16483
|
if (respectTimezone && contact.timezone) {
|
|
16214
|
-
|
|
16484
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
16485
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
16215
16486
|
}
|
|
16216
16487
|
const doc = {
|
|
16217
16488
|
_id: sendId,
|
|
@@ -16250,7 +16521,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16250
16521
|
};
|
|
16251
16522
|
return { doc, delayMs };
|
|
16252
16523
|
}
|
|
16253
|
-
function
|
|
16524
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16525
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16254
16526
|
try {
|
|
16255
16527
|
const scheduled = new Date(scheduledMs);
|
|
16256
16528
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -16263,7 +16535,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
16263
16535
|
const utcMs = parse(utc);
|
|
16264
16536
|
const localMs = parse(local);
|
|
16265
16537
|
const offsetMs = utcMs - localMs;
|
|
16266
|
-
return offsetMs;
|
|
16538
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
16267
16539
|
} catch {
|
|
16268
16540
|
return 0;
|
|
16269
16541
|
}
|
|
@@ -16938,6 +17210,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
|
|
|
16938
17210
|
|
|
16939
17211
|
// src/server/runner/tick.ts
|
|
16940
17212
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
17213
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16941
17214
|
async function runTick(ctx) {
|
|
16942
17215
|
try {
|
|
16943
17216
|
await ctx.collections.health.updateOne(
|
|
@@ -16978,12 +17251,18 @@ async function runTick(ctx) {
|
|
|
16978
17251
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
16979
17252
|
console.error("mailery: broadcast dispatch failed", err);
|
|
16980
17253
|
});
|
|
17254
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17255
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17256
|
+
});
|
|
16981
17257
|
await evaluateHealth(ctx).catch((err) => {
|
|
16982
17258
|
console.error("mailery: health evaluation failed", err);
|
|
16983
17259
|
});
|
|
16984
17260
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
16985
17261
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
16986
17262
|
});
|
|
17263
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
17264
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
17265
|
+
});
|
|
16987
17266
|
await Promise.all([
|
|
16988
17267
|
runDnsblChecks(ctx).catch((err) => {
|
|
16989
17268
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -17054,140 +17333,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
17054
17333
|
await processScheduledBroadcasts(ctx);
|
|
17055
17334
|
}
|
|
17056
17335
|
|
|
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
17336
|
// src/server/mailer.ts
|
|
17192
17337
|
var Mailer = class _Mailer {
|
|
17193
17338
|
db;
|
|
@@ -17227,6 +17372,7 @@ var Mailer = class _Mailer {
|
|
|
17227
17372
|
* MAILER_MONGODB_URI — Mongo connection string (required)
|
|
17228
17373
|
* MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
|
|
17229
17374
|
* MAILER_REDIS_URL — Redis connection URL (required)
|
|
17375
|
+
* MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
|
|
17230
17376
|
* MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
|
|
17231
17377
|
* MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
|
|
17232
17378
|
* MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
|
|
@@ -17275,7 +17421,11 @@ var Mailer = class _Mailer {
|
|
|
17275
17421
|
}
|
|
17276
17422
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
17277
17423
|
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
17278
|
-
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
17424
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
17425
|
+
driver: "bull",
|
|
17426
|
+
redis: { url: required("MAILER_REDIS_URL") },
|
|
17427
|
+
prefix: env.MAILER_QUEUE_PREFIX
|
|
17428
|
+
};
|
|
17279
17429
|
return _Mailer.init({
|
|
17280
17430
|
db,
|
|
17281
17431
|
adapter,
|
|
@@ -17652,41 +17802,48 @@ var Mailer = class _Mailer {
|
|
|
17652
17802
|
if (existing) return { sendId: String(existing._id) };
|
|
17653
17803
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
17654
17804
|
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
|
-
|
|
17805
|
+
try {
|
|
17806
|
+
await this.collections.sends.insertOne({
|
|
17807
|
+
_id: sendId,
|
|
17808
|
+
dedupeKey,
|
|
17809
|
+
externalId: parsed.externalId,
|
|
17810
|
+
emailAtSend: contact.email,
|
|
17811
|
+
templateId: template._id,
|
|
17812
|
+
templateSlug: template.slug,
|
|
17813
|
+
flowRunId: null,
|
|
17814
|
+
broadcastId: null,
|
|
17815
|
+
manualSendBy: "sendOneOff",
|
|
17816
|
+
kind: template.kind,
|
|
17817
|
+
provider: providerName,
|
|
17818
|
+
providerMessageId: null,
|
|
17819
|
+
fromName: template.fromName,
|
|
17820
|
+
fromEmail: template.fromEmail,
|
|
17821
|
+
subject: template.subject,
|
|
17822
|
+
bodyHash: "",
|
|
17823
|
+
status: "queued",
|
|
17824
|
+
errorMessage: null,
|
|
17825
|
+
bounceType: null,
|
|
17826
|
+
bounceReason: null,
|
|
17827
|
+
links: [],
|
|
17828
|
+
vars: parsed.vars ?? {},
|
|
17829
|
+
openedAt: null,
|
|
17830
|
+
openCount: 0,
|
|
17831
|
+
firstClickAt: null,
|
|
17832
|
+
clickCount: 0,
|
|
17833
|
+
clickedLinks: [],
|
|
17834
|
+
unsubscribedAt: null,
|
|
17835
|
+
complainedAt: null,
|
|
17836
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
17837
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17838
|
+
sentAt: null,
|
|
17839
|
+
deliveredAt: null
|
|
17840
|
+
});
|
|
17841
|
+
} catch (err) {
|
|
17842
|
+
if (err?.code !== 11e3) throw err;
|
|
17843
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
17844
|
+
if (winner) return { sendId: String(winner._id) };
|
|
17845
|
+
throw err;
|
|
17846
|
+
}
|
|
17690
17847
|
await this.queues.send.add(
|
|
17691
17848
|
"send",
|
|
17692
17849
|
{ sendId: String(sendId) },
|
|
@@ -17730,7 +17887,12 @@ var Mailer = class _Mailer {
|
|
|
17730
17887
|
await runTick(this.runnerContext);
|
|
17731
17888
|
},
|
|
17732
17889
|
advance: async (data) => {
|
|
17733
|
-
if (
|
|
17890
|
+
if (data.broadcastId) {
|
|
17891
|
+
if (!mongodb.ObjectId.isValid(data.broadcastId)) return;
|
|
17892
|
+
await dispatchBroadcastById(new mongodb.ObjectId(data.broadcastId), this.runnerContext);
|
|
17893
|
+
return;
|
|
17894
|
+
}
|
|
17895
|
+
if (!data.flowRunId || !mongodb.ObjectId.isValid(data.flowRunId)) return;
|
|
17734
17896
|
await processOneRunStep(new mongodb.ObjectId(data.flowRunId), this.runnerContext);
|
|
17735
17897
|
},
|
|
17736
17898
|
send: async (data) => {
|
|
@@ -17746,27 +17908,7 @@ var Mailer = class _Mailer {
|
|
|
17746
17908
|
}
|
|
17747
17909
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
17748
17910
|
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
|
-
}
|
|
17911
|
+
await processWebhookBacklog(this.runnerContext);
|
|
17770
17912
|
}
|
|
17771
17913
|
async stop() {
|
|
17772
17914
|
await this.queueDriver.close();
|