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.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 },
|
|
@@ -14617,7 +14623,7 @@ var BullDriver = class _BullDriver {
|
|
|
14617
14623
|
bullQueues;
|
|
14618
14624
|
workers = null;
|
|
14619
14625
|
bull;
|
|
14620
|
-
static async create(redisConfig) {
|
|
14626
|
+
static async create(redisConfig, prefix) {
|
|
14621
14627
|
let bull;
|
|
14622
14628
|
try {
|
|
14623
14629
|
bull = await import('bullmq');
|
|
@@ -14626,13 +14632,27 @@ var BullDriver = class _BullDriver {
|
|
|
14626
14632
|
"mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
|
|
14627
14633
|
);
|
|
14628
14634
|
}
|
|
14635
|
+
if (prefix?.includes(":")) {
|
|
14636
|
+
throw new Error(
|
|
14637
|
+
`mailery: queue prefix "${prefix}" must not contain ':' \u2014 BullMQ uses it as the Redis key separator.`
|
|
14638
|
+
);
|
|
14639
|
+
}
|
|
14629
14640
|
const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
|
|
14630
|
-
return new _BullDriver(bull, redis);
|
|
14641
|
+
return new _BullDriver(bull, redis, prefix);
|
|
14631
14642
|
}
|
|
14632
|
-
|
|
14643
|
+
prefix;
|
|
14644
|
+
constructor(bull, redis, prefix) {
|
|
14633
14645
|
this.bull = bull;
|
|
14634
14646
|
this.redis = redis;
|
|
14635
|
-
|
|
14647
|
+
this.prefix = prefix;
|
|
14648
|
+
const opts = {
|
|
14649
|
+
connection: redis,
|
|
14650
|
+
prefix,
|
|
14651
|
+
defaultJobOptions: {
|
|
14652
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
14653
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
14654
|
+
}
|
|
14655
|
+
};
|
|
14636
14656
|
this.bullQueues = {
|
|
14637
14657
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
14638
14658
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -14650,12 +14670,16 @@ var BullDriver = class _BullDriver {
|
|
|
14650
14670
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
14651
14671
|
"mailer-tick-repeat",
|
|
14652
14672
|
{ every: intervalSeconds * 1e3 },
|
|
14653
|
-
{
|
|
14673
|
+
{
|
|
14674
|
+
name: "tick",
|
|
14675
|
+
data: {},
|
|
14676
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
14677
|
+
}
|
|
14654
14678
|
);
|
|
14655
14679
|
}
|
|
14656
14680
|
async startWorkers(opts) {
|
|
14657
14681
|
if (this.workers) return;
|
|
14658
|
-
const base = { connection: this.redis };
|
|
14682
|
+
const base = { connection: this.redis, prefix: this.prefix };
|
|
14659
14683
|
const { Worker } = this.bull;
|
|
14660
14684
|
const tick = new Worker(
|
|
14661
14685
|
QUEUE_NAMES.tick,
|
|
@@ -14760,9 +14784,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14760
14784
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
14761
14785
|
);
|
|
14762
14786
|
}
|
|
14787
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
14763
14788
|
const backend = new backendMod.MongoBackend({
|
|
14764
14789
|
mongo: opts.db,
|
|
14765
|
-
collection:
|
|
14790
|
+
collection: collectionName
|
|
14766
14791
|
});
|
|
14767
14792
|
const agenda = new agendaMod.Agenda({
|
|
14768
14793
|
backend,
|
|
@@ -14771,13 +14796,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14771
14796
|
maxConcurrency: 50,
|
|
14772
14797
|
defaultConcurrency: 5
|
|
14773
14798
|
});
|
|
14774
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
14799
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
14775
14800
|
}
|
|
14776
14801
|
db;
|
|
14777
|
-
|
|
14802
|
+
collName;
|
|
14803
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
14778
14804
|
this.agenda = agenda;
|
|
14779
14805
|
this.agendaMod = agendaMod;
|
|
14780
14806
|
this.db = db;
|
|
14807
|
+
this.collName = collectionName;
|
|
14781
14808
|
this.queues = {
|
|
14782
14809
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
14783
14810
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -14810,10 +14837,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14810
14837
|
}
|
|
14811
14838
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
14812
14839
|
jobsCollection() {
|
|
14813
|
-
return this.db.collection(this.
|
|
14814
|
-
}
|
|
14815
|
-
collectionName() {
|
|
14816
|
-
return "_mailerJobs";
|
|
14840
|
+
return this.db.collection(this.collName);
|
|
14817
14841
|
}
|
|
14818
14842
|
async findPending(name, jobId) {
|
|
14819
14843
|
return this.jobsCollection().findOne({
|
|
@@ -14823,12 +14847,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14823
14847
|
});
|
|
14824
14848
|
}
|
|
14825
14849
|
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
14850
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
14833
14851
|
}
|
|
14834
14852
|
async startWorkers(opts) {
|
|
@@ -14913,7 +14931,7 @@ var NoopDriver = class {
|
|
|
14913
14931
|
async function createQueueDriver(config, fallbackDb) {
|
|
14914
14932
|
switch (config.driver) {
|
|
14915
14933
|
case "bull":
|
|
14916
|
-
return BullDriver.create(config.redis);
|
|
14934
|
+
return BullDriver.create(config.redis, config.prefix);
|
|
14917
14935
|
case "agenda":
|
|
14918
14936
|
return AgendaDriver.create({
|
|
14919
14937
|
db: config.db ?? fallbackDb,
|
|
@@ -14931,6 +14949,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
14931
14949
|
|
|
14932
14950
|
// src/server/runner/triggers.ts
|
|
14933
14951
|
var BATCH_SIZE = 1e3;
|
|
14952
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
14934
14953
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
14935
14954
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
14936
14955
|
for (const flow of flows) {
|
|
@@ -14941,16 +14960,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
14941
14960
|
const eventName = flow.trigger.eventName;
|
|
14942
14961
|
if (!eventName) return;
|
|
14943
14962
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
14944
|
-
const
|
|
14963
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
14964
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
14945
14965
|
if (events.length === 0) return;
|
|
14946
14966
|
for (const event of events) {
|
|
14947
14967
|
await tryEnterFlow(flow, event, ctx);
|
|
14948
14968
|
}
|
|
14949
|
-
const
|
|
14950
|
-
|
|
14951
|
-
|
|
14952
|
-
|
|
14953
|
-
|
|
14969
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
14970
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
14971
|
+
await ctx.collections.flows.updateOne(
|
|
14972
|
+
{ _id: flow._id },
|
|
14973
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
14974
|
+
);
|
|
14975
|
+
}
|
|
14954
14976
|
}
|
|
14955
14977
|
async function tryEnterFlow(flow, event, ctx) {
|
|
14956
14978
|
if (flow.trigger.once) {
|
|
@@ -14962,117 +14984,453 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14962
14984
|
}
|
|
14963
14985
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
14964
14986
|
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
|
-
|
|
14987
|
+
let result;
|
|
14988
|
+
try {
|
|
14989
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
14990
|
+
externalId: event.externalId,
|
|
14991
|
+
flowId: flow._id,
|
|
14992
|
+
flowSlug: flow.slug,
|
|
14993
|
+
flowVersion: flow.version,
|
|
14994
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
14995
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
14996
|
+
triggerDedupeKey: event.dedupeKey,
|
|
14997
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
14998
|
+
status: "active",
|
|
14999
|
+
currentStepIndex: 0,
|
|
15000
|
+
currentBranchPath: [],
|
|
15001
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
15002
|
+
attemptsForCurrentStep: 0,
|
|
15003
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
15004
|
+
exitedAt: null,
|
|
15005
|
+
exitReason: null,
|
|
15006
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
15007
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15008
|
+
});
|
|
15009
|
+
} catch (err) {
|
|
15010
|
+
if (err?.code !== 11e3) throw err;
|
|
15011
|
+
return;
|
|
15012
|
+
}
|
|
14984
15013
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
14985
15014
|
}
|
|
14986
15015
|
|
|
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;
|
|
15016
|
+
// src/server/templates/sender-domain.ts
|
|
15017
|
+
function extractDomain(email) {
|
|
15018
|
+
if (typeof email !== "string") return null;
|
|
15019
|
+
const at = email.lastIndexOf("@");
|
|
15020
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15021
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15022
|
+
}
|
|
15023
|
+
|
|
15024
|
+
// src/server/runner/health.ts
|
|
15025
|
+
var ZERO_COUNTERS = {
|
|
15026
|
+
sent: 0,
|
|
15027
|
+
delivered: 0,
|
|
15028
|
+
bounced: 0,
|
|
15029
|
+
hardBounced: 0,
|
|
15030
|
+
softBounced: 0,
|
|
15031
|
+
complained: 0,
|
|
15032
|
+
failedToSend: 0
|
|
15033
|
+
};
|
|
15034
|
+
var ZERO_RATES = {
|
|
15035
|
+
bounceRate: 0,
|
|
15036
|
+
hardBounceRate: 0,
|
|
15037
|
+
complaintRate: 0,
|
|
15038
|
+
failureRate: 0
|
|
15039
|
+
};
|
|
15040
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15041
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15042
|
+
const writes = [];
|
|
15043
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15044
|
+
if (dims) {
|
|
15045
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15046
|
+
if (domain) {
|
|
15047
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15048
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15024
15049
|
}
|
|
15025
|
-
return false;
|
|
15026
15050
|
}
|
|
15027
|
-
|
|
15028
|
-
return !await evaluatePredicate(p.not, ctx);
|
|
15029
|
-
}
|
|
15030
|
-
return false;
|
|
15051
|
+
await Promise.all(writes);
|
|
15031
15052
|
}
|
|
15032
|
-
async function
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15037
|
-
|
|
15053
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15054
|
+
await ctx.collections.health.updateOne(
|
|
15055
|
+
{ _id },
|
|
15056
|
+
{
|
|
15057
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
15058
|
+
$setOnInsert: {
|
|
15059
|
+
_id,
|
|
15060
|
+
senderDomain,
|
|
15061
|
+
kind,
|
|
15062
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15063
|
+
windowDurationMs: windowMs,
|
|
15064
|
+
status: "healthy",
|
|
15065
|
+
trippedAt: null,
|
|
15066
|
+
trippedReason: null,
|
|
15067
|
+
manuallyResumedAt: null,
|
|
15068
|
+
rates: { ...ZERO_RATES }
|
|
15069
|
+
},
|
|
15070
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15071
|
+
},
|
|
15072
|
+
{ upsert: true }
|
|
15073
|
+
);
|
|
15038
15074
|
}
|
|
15039
|
-
async function
|
|
15040
|
-
const
|
|
15041
|
-
|
|
15042
|
-
|
|
15043
|
-
if (
|
|
15044
|
-
const
|
|
15045
|
-
if (
|
|
15046
|
-
|
|
15047
|
-
return await ctx.collections.sends.countDocuments(filter);
|
|
15075
|
+
async function evaluateHealth(ctx) {
|
|
15076
|
+
const cb = ctx.config.circuitBreaker;
|
|
15077
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15078
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15079
|
+
if (docs.length === 0) return;
|
|
15080
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15081
|
+
if (!hasAgg) {
|
|
15082
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15048
15083
|
}
|
|
15049
|
-
const
|
|
15050
|
-
|
|
15051
|
-
|
|
15052
|
-
if (
|
|
15053
|
-
|
|
15084
|
+
for (const doc of docs) {
|
|
15085
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15086
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15087
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15088
|
+
await ctx.collections.health.updateOne(
|
|
15089
|
+
{ _id: doc._id },
|
|
15090
|
+
{
|
|
15091
|
+
$set: {
|
|
15092
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15093
|
+
windowDurationMs: windowMs,
|
|
15094
|
+
counters: { ...ZERO_COUNTERS },
|
|
15095
|
+
rates: { ...ZERO_RATES },
|
|
15096
|
+
status: "healthy",
|
|
15097
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15098
|
+
}
|
|
15099
|
+
}
|
|
15100
|
+
);
|
|
15054
15101
|
continue;
|
|
15055
15102
|
}
|
|
15056
|
-
const
|
|
15057
|
-
|
|
15103
|
+
const c = doc.counters;
|
|
15104
|
+
const total = c.sent || 1;
|
|
15105
|
+
const rates = {
|
|
15106
|
+
bounceRate: c.bounced / total,
|
|
15107
|
+
hardBounceRate: c.hardBounced / total,
|
|
15108
|
+
complaintRate: c.complained / total,
|
|
15109
|
+
failureRate: c.failedToSend / total
|
|
15110
|
+
};
|
|
15111
|
+
await ctx.collections.health.updateOne(
|
|
15112
|
+
{ _id: doc._id },
|
|
15113
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15114
|
+
);
|
|
15115
|
+
if (isAgg) continue;
|
|
15116
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15117
|
+
if (doc.status === "tripped") continue;
|
|
15118
|
+
let trippedReason = null;
|
|
15119
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15120
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15121
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15122
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15123
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15124
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15125
|
+
}
|
|
15126
|
+
if (trippedReason) {
|
|
15127
|
+
const result = await ctx.collections.health.updateOne(
|
|
15128
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15129
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15130
|
+
);
|
|
15131
|
+
if (result.modifiedCount > 0) {
|
|
15132
|
+
if (ctx.audit) {
|
|
15133
|
+
try {
|
|
15134
|
+
await ctx.audit({
|
|
15135
|
+
actor: "system:circuit-breaker",
|
|
15136
|
+
action: "health.trip",
|
|
15137
|
+
resource: {
|
|
15138
|
+
collection: "mailer_health",
|
|
15139
|
+
id: String(doc._id),
|
|
15140
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15141
|
+
},
|
|
15142
|
+
diffSummary: trippedReason
|
|
15143
|
+
});
|
|
15144
|
+
} catch {
|
|
15145
|
+
}
|
|
15146
|
+
}
|
|
15147
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15148
|
+
try {
|
|
15149
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15150
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15151
|
+
rates
|
|
15152
|
+
});
|
|
15153
|
+
} catch {
|
|
15154
|
+
}
|
|
15155
|
+
}
|
|
15156
|
+
}
|
|
15157
|
+
continue;
|
|
15158
|
+
}
|
|
15159
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15160
|
+
if (doc.status !== "degraded") {
|
|
15161
|
+
await ctx.collections.health.updateOne(
|
|
15162
|
+
{ _id: doc._id },
|
|
15163
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15164
|
+
);
|
|
15165
|
+
}
|
|
15166
|
+
} else if (doc.status === "degraded") {
|
|
15167
|
+
await ctx.collections.health.updateOne(
|
|
15168
|
+
{ _id: doc._id },
|
|
15169
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15170
|
+
);
|
|
15171
|
+
}
|
|
15058
15172
|
}
|
|
15059
|
-
return n;
|
|
15060
15173
|
}
|
|
15061
|
-
function
|
|
15062
|
-
const
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15066
|
-
}
|
|
15067
|
-
return null;
|
|
15174
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15175
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15176
|
+
const id = healthBucketId(domain, kind);
|
|
15177
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15068
15178
|
}
|
|
15069
15179
|
|
|
15070
|
-
// src/server/runner/
|
|
15071
|
-
|
|
15072
|
-
|
|
15073
|
-
|
|
15074
|
-
|
|
15075
|
-
|
|
15180
|
+
// src/server/runner/webhook.ts
|
|
15181
|
+
function dimsFromSend(send) {
|
|
15182
|
+
if (!send) return null;
|
|
15183
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
15184
|
+
}
|
|
15185
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
15186
|
+
const filter = { processed: false };
|
|
15187
|
+
if (opts.olderThanMs) {
|
|
15188
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
15189
|
+
}
|
|
15190
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
15191
|
+
for (const evt of batch) {
|
|
15192
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
15193
|
+
{ _id: evt._id, processed: false },
|
|
15194
|
+
{ $set: { processed: true } }
|
|
15195
|
+
);
|
|
15196
|
+
if (!claimed) continue;
|
|
15197
|
+
try {
|
|
15198
|
+
const normalized = evt.raw?.normalized;
|
|
15199
|
+
const details = normalized?.details ?? {};
|
|
15200
|
+
await applyWebhookEvent(
|
|
15201
|
+
{
|
|
15202
|
+
type: evt.normalizedType,
|
|
15203
|
+
providerEventId: evt.providerEventId,
|
|
15204
|
+
providerMessageId: evt.providerMessageId,
|
|
15205
|
+
email: evt.email,
|
|
15206
|
+
occurredAt: evt.occurredAt,
|
|
15207
|
+
details
|
|
15208
|
+
},
|
|
15209
|
+
ctx
|
|
15210
|
+
);
|
|
15211
|
+
} catch (err) {
|
|
15212
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
15213
|
+
}
|
|
15214
|
+
}
|
|
15215
|
+
}
|
|
15216
|
+
async function applyWebhookEvent(event, ctx) {
|
|
15217
|
+
const send = await ctx.collections.sends.findOne(
|
|
15218
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
15219
|
+
{ sort: { queuedAt: -1 } }
|
|
15220
|
+
);
|
|
15221
|
+
switch (event.type) {
|
|
15222
|
+
case "delivered":
|
|
15223
|
+
if (send) {
|
|
15224
|
+
await ctx.collections.sends.updateOne(
|
|
15225
|
+
{ _id: send._id },
|
|
15226
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
15227
|
+
);
|
|
15228
|
+
}
|
|
15229
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
15230
|
+
break;
|
|
15231
|
+
case "open":
|
|
15232
|
+
if (send) {
|
|
15233
|
+
await ctx.collections.sends.updateOne(
|
|
15234
|
+
{ _id: send._id },
|
|
15235
|
+
{
|
|
15236
|
+
$set: {
|
|
15237
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
15238
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
15239
|
+
},
|
|
15240
|
+
$inc: { openCount: 1 }
|
|
15241
|
+
}
|
|
15242
|
+
);
|
|
15243
|
+
}
|
|
15244
|
+
break;
|
|
15245
|
+
case "click":
|
|
15246
|
+
if (send) {
|
|
15247
|
+
await ctx.collections.sends.updateOne(
|
|
15248
|
+
{ _id: send._id },
|
|
15249
|
+
{
|
|
15250
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
15251
|
+
$inc: { clickCount: 1 },
|
|
15252
|
+
$push: {
|
|
15253
|
+
clickedLinks: {
|
|
15254
|
+
url: event.details.clickedUrl ?? "",
|
|
15255
|
+
linkId: "",
|
|
15256
|
+
clickedAt: event.occurredAt
|
|
15257
|
+
}
|
|
15258
|
+
}
|
|
15259
|
+
}
|
|
15260
|
+
);
|
|
15261
|
+
}
|
|
15262
|
+
break;
|
|
15263
|
+
case "bounce": {
|
|
15264
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
15265
|
+
if (send) {
|
|
15266
|
+
await ctx.collections.sends.updateOne(
|
|
15267
|
+
{ _id: send._id },
|
|
15268
|
+
{
|
|
15269
|
+
$set: {
|
|
15270
|
+
status: "bounced",
|
|
15271
|
+
bounceType,
|
|
15272
|
+
bounceReason: event.details.bounceReason ?? null
|
|
15273
|
+
}
|
|
15274
|
+
}
|
|
15275
|
+
);
|
|
15276
|
+
}
|
|
15277
|
+
if (bounceType === "hard") {
|
|
15278
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
15279
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15280
|
+
{ emailAtSubscribe: event.email },
|
|
15281
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15282
|
+
);
|
|
15283
|
+
}
|
|
15284
|
+
{
|
|
15285
|
+
const dims = dimsFromSend(send);
|
|
15286
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
15287
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
15288
|
+
}
|
|
15289
|
+
break;
|
|
15290
|
+
}
|
|
15291
|
+
case "complaint":
|
|
15292
|
+
case "spam_report":
|
|
15293
|
+
if (send) {
|
|
15294
|
+
await ctx.collections.sends.updateOne(
|
|
15295
|
+
{ _id: send._id },
|
|
15296
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
15297
|
+
);
|
|
15298
|
+
}
|
|
15299
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
15300
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15301
|
+
{ emailAtSubscribe: event.email },
|
|
15302
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15303
|
+
);
|
|
15304
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
15305
|
+
break;
|
|
15306
|
+
case "unsubscribe":
|
|
15307
|
+
if (send) {
|
|
15308
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
15309
|
+
}
|
|
15310
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
15311
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15312
|
+
{ emailAtSubscribe: event.email },
|
|
15313
|
+
{
|
|
15314
|
+
$set: {
|
|
15315
|
+
status: "unsubscribed",
|
|
15316
|
+
unsubscribedAt: event.occurredAt,
|
|
15317
|
+
unsubscribeReason: "user_request",
|
|
15318
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15319
|
+
}
|
|
15320
|
+
}
|
|
15321
|
+
);
|
|
15322
|
+
break;
|
|
15323
|
+
}
|
|
15324
|
+
}
|
|
15325
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
15326
|
+
const normalized = email.toLowerCase();
|
|
15327
|
+
await ctx.collections.suppressions.updateOne(
|
|
15328
|
+
{ email: normalized, scope },
|
|
15329
|
+
{
|
|
15330
|
+
$setOnInsert: {
|
|
15331
|
+
email: normalized,
|
|
15332
|
+
emailHash: sha256Hex(normalized),
|
|
15333
|
+
scope,
|
|
15334
|
+
reason,
|
|
15335
|
+
source: "provider_webhook",
|
|
15336
|
+
notes: null,
|
|
15337
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
15338
|
+
expiresAt: null
|
|
15339
|
+
}
|
|
15340
|
+
},
|
|
15341
|
+
{ upsert: true }
|
|
15342
|
+
);
|
|
15343
|
+
}
|
|
15344
|
+
|
|
15345
|
+
// src/server/runner/predicate.ts
|
|
15346
|
+
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
15347
|
+
async function evaluatePredicate(predicate, ctx) {
|
|
15348
|
+
const p = predicate;
|
|
15349
|
+
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
15350
|
+
if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
|
|
15351
|
+
if ("fieldEquals" in p) {
|
|
15352
|
+
return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
|
|
15353
|
+
}
|
|
15354
|
+
if ("fieldExists" in p) {
|
|
15355
|
+
return ctx.contact.fields[p.fieldExists] !== void 0;
|
|
15356
|
+
}
|
|
15357
|
+
if ("subscriptionStatus" in p) {
|
|
15358
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
|
|
15359
|
+
return sub?.status === p.subscriptionStatus;
|
|
15360
|
+
}
|
|
15361
|
+
if ("hasFiredEvent" in p) {
|
|
15362
|
+
return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
|
|
15363
|
+
}
|
|
15364
|
+
if ("notHasFiredEvent" in p) {
|
|
15365
|
+
return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
|
|
15366
|
+
}
|
|
15367
|
+
if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
|
|
15368
|
+
if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
|
|
15369
|
+
if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
|
|
15370
|
+
if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
|
|
15371
|
+
if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
|
|
15372
|
+
if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
|
|
15373
|
+
if ("all" in p) {
|
|
15374
|
+
for (const sub of p.all) {
|
|
15375
|
+
if (!await evaluatePredicate(sub, ctx)) return false;
|
|
15376
|
+
}
|
|
15377
|
+
return true;
|
|
15378
|
+
}
|
|
15379
|
+
if ("any" in p) {
|
|
15380
|
+
for (const sub of p.any) {
|
|
15381
|
+
if (await evaluatePredicate(sub, ctx)) return true;
|
|
15382
|
+
}
|
|
15383
|
+
return false;
|
|
15384
|
+
}
|
|
15385
|
+
if ("not" in p) {
|
|
15386
|
+
return !await evaluatePredicate(p.not, ctx);
|
|
15387
|
+
}
|
|
15388
|
+
return false;
|
|
15389
|
+
}
|
|
15390
|
+
async function hasEvent(ctx, name, opts) {
|
|
15391
|
+
const filter = { externalId: ctx.contact.externalId, name };
|
|
15392
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15393
|
+
if (lower) filter.occurredAt = { $gt: lower };
|
|
15394
|
+
const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
|
|
15395
|
+
return !!found;
|
|
15396
|
+
}
|
|
15397
|
+
async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
15398
|
+
const filter = { externalId: ctx.contact.externalId };
|
|
15399
|
+
if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
|
|
15400
|
+
if (kind === "opened") filter.openedAt = { $ne: null };
|
|
15401
|
+
if (kind === "clicked") filter.firstClickAt = { $ne: null };
|
|
15402
|
+
const lower = effectiveLowerBound(ctx, opts);
|
|
15403
|
+
if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
|
|
15404
|
+
if (!excludeBots) {
|
|
15405
|
+
return await ctx.collections.sends.countDocuments(filter);
|
|
15406
|
+
}
|
|
15407
|
+
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
15408
|
+
let n = 0;
|
|
15409
|
+
for (const s of docs) {
|
|
15410
|
+
if (kind === "opened") {
|
|
15411
|
+
n++;
|
|
15412
|
+
continue;
|
|
15413
|
+
}
|
|
15414
|
+
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
15415
|
+
if (hasHumanClick) n++;
|
|
15416
|
+
}
|
|
15417
|
+
return n;
|
|
15418
|
+
}
|
|
15419
|
+
function effectiveLowerBound(ctx, opts) {
|
|
15420
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
15421
|
+
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
15422
|
+
if (opts.withinDays && opts.withinDays > 0) {
|
|
15423
|
+
return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
|
|
15424
|
+
}
|
|
15425
|
+
return null;
|
|
15426
|
+
}
|
|
15427
|
+
|
|
15428
|
+
// src/server/runner/delivery-window.ts
|
|
15429
|
+
var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
|
|
15430
|
+
function computeDeliveryTime(now, window2, contactTimezone) {
|
|
15431
|
+
const tz = pickTimezone(window2, contactTimezone);
|
|
15432
|
+
let candidate = now;
|
|
15433
|
+
if (window2.timeOfDay) {
|
|
15076
15434
|
const [hh, mm] = window2.timeOfDay.split(":").map(Number);
|
|
15077
15435
|
const local = localParts(candidate, tz);
|
|
15078
15436
|
const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
|
|
@@ -15207,7 +15565,8 @@ function applyTracking(html, opts) {
|
|
|
15207
15565
|
const seen = /* @__PURE__ */ new Map();
|
|
15208
15566
|
let out = html;
|
|
15209
15567
|
if (opts.trackClicks) {
|
|
15210
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
15568
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
15569
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
15211
15570
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
15212
15571
|
let linkId = seen.get(url);
|
|
15213
15572
|
if (!linkId) {
|
|
@@ -15239,236 +15598,95 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
15239
15598
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
15240
15599
|
return false;
|
|
15241
15600
|
}
|
|
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"]
|
|
15292
|
-
};
|
|
15293
|
-
async function isSuppressed(collections, email, kind) {
|
|
15294
|
-
const normalized = email.toLowerCase();
|
|
15295
|
-
const allowed = SCOPES_BY_KIND[kind];
|
|
15296
|
-
const byEmail = await collections.suppressions.findOne({
|
|
15297
|
-
email: normalized,
|
|
15298
|
-
scope: { $in: allowed },
|
|
15299
|
-
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
15300
|
-
});
|
|
15301
|
-
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
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
|
|
15601
|
+
var NAMED_ENTITIES = {
|
|
15602
|
+
amp: "&",
|
|
15603
|
+
lt: "<",
|
|
15604
|
+
gt: ">",
|
|
15605
|
+
quot: '"',
|
|
15606
|
+
apos: "'",
|
|
15607
|
+
nbsp: "\xA0"
|
|
15333
15608
|
};
|
|
15334
|
-
|
|
15335
|
-
|
|
15336
|
-
|
|
15337
|
-
|
|
15338
|
-
|
|
15339
|
-
|
|
15340
|
-
|
|
15341
|
-
|
|
15342
|
-
|
|
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
|
-
}
|
|
15609
|
+
function decodeHtmlEntities(input) {
|
|
15610
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
15611
|
+
if (body[0] === "#") {
|
|
15612
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
15613
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
15614
|
+
try {
|
|
15615
|
+
return String.fromCodePoint(code);
|
|
15616
|
+
} catch {
|
|
15617
|
+
return match;
|
|
15450
15618
|
}
|
|
15451
|
-
continue;
|
|
15452
15619
|
}
|
|
15453
|
-
|
|
15454
|
-
|
|
15455
|
-
|
|
15456
|
-
|
|
15457
|
-
|
|
15458
|
-
|
|
15459
|
-
|
|
15460
|
-
|
|
15461
|
-
|
|
15462
|
-
|
|
15463
|
-
|
|
15464
|
-
|
|
15620
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
15621
|
+
return named ?? match;
|
|
15622
|
+
});
|
|
15623
|
+
}
|
|
15624
|
+
function shortHash(input) {
|
|
15625
|
+
return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
15626
|
+
}
|
|
15627
|
+
function makeHandlebars(extra) {
|
|
15628
|
+
const hb = Handlebars.create();
|
|
15629
|
+
hb.registerHelper("eq", (a, b) => a === b);
|
|
15630
|
+
hb.registerHelper("ne", (a, b) => a !== b);
|
|
15631
|
+
hb.registerHelper("gt", (a, b) => a > b);
|
|
15632
|
+
hb.registerHelper("lt", (a, b) => a < b);
|
|
15633
|
+
hb.registerHelper("gte", (a, b) => a >= b);
|
|
15634
|
+
hb.registerHelper("lte", (a, b) => a <= b);
|
|
15635
|
+
hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
15636
|
+
hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
15637
|
+
hb.registerHelper("not", (a) => !a);
|
|
15638
|
+
hb.registerHelper("formatDate", (value, fmt) => {
|
|
15639
|
+
if (!value) return "";
|
|
15640
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
15641
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
15642
|
+
if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
|
|
15643
|
+
if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
|
|
15644
|
+
return d.toISOString().slice(0, 10);
|
|
15645
|
+
});
|
|
15646
|
+
hb.registerHelper("formatNumber", (n) => {
|
|
15647
|
+
const v = Number(n);
|
|
15648
|
+
return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
|
|
15649
|
+
});
|
|
15650
|
+
hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
|
|
15651
|
+
const v = Number(cents);
|
|
15652
|
+
if (!Number.isFinite(v)) return "";
|
|
15653
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
|
|
15654
|
+
});
|
|
15655
|
+
hb.registerHelper(
|
|
15656
|
+
"pluralize",
|
|
15657
|
+
(n, one, many) => Number(n) === 1 ? one : many
|
|
15658
|
+
);
|
|
15659
|
+
if (extra) {
|
|
15660
|
+
for (const [name, fn] of Object.entries(extra)) {
|
|
15661
|
+
hb.registerHelper(name, fn);
|
|
15465
15662
|
}
|
|
15466
15663
|
}
|
|
15664
|
+
return hb;
|
|
15467
15665
|
}
|
|
15468
|
-
|
|
15469
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
15666
|
+
|
|
15667
|
+
// src/server/runner/send.ts
|
|
15668
|
+
init_vars();
|
|
15669
|
+
|
|
15670
|
+
// src/server/runner/suppression.ts
|
|
15671
|
+
var SCOPES_BY_KIND = {
|
|
15672
|
+
marketing: ["all", "marketing"],
|
|
15673
|
+
transactional: ["all", "transactional"]
|
|
15674
|
+
};
|
|
15675
|
+
async function isSuppressed(collections, email, kind) {
|
|
15676
|
+
const normalized = email.toLowerCase();
|
|
15677
|
+
const allowed = SCOPES_BY_KIND[kind];
|
|
15678
|
+
const byEmail = await collections.suppressions.findOne({
|
|
15679
|
+
email: normalized,
|
|
15680
|
+
scope: { $in: allowed },
|
|
15681
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
|
|
15682
|
+
});
|
|
15683
|
+
if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
|
|
15684
|
+
const hashed = await collections.suppressions.findOne({
|
|
15685
|
+
emailHash: sha256Hex(normalized),
|
|
15686
|
+
scope: { $in: allowed }
|
|
15687
|
+
});
|
|
15688
|
+
if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
|
|
15689
|
+
return { suppressed: false };
|
|
15472
15690
|
}
|
|
15473
15691
|
|
|
15474
15692
|
// src/server/runner/send.ts
|
|
@@ -15536,9 +15754,11 @@ async function handleSend(run, step2, contact, flow, ctx) {
|
|
|
15536
15754
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
15537
15755
|
}
|
|
15538
15756
|
async function dispatchSend(sendId, ctx) {
|
|
15539
|
-
const send = await ctx.collections.sends.
|
|
15757
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
15758
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
15759
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15760
|
+
);
|
|
15540
15761
|
if (!send) return;
|
|
15541
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
15542
15762
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
15543
15763
|
if (!template) {
|
|
15544
15764
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -15555,6 +15775,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15555
15775
|
if (send.kind === "marketing") {
|
|
15556
15776
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15557
15777
|
if (bucket?.status === "tripped") {
|
|
15778
|
+
await ctx.collections.sends.updateOne(
|
|
15779
|
+
{ _id: send._id },
|
|
15780
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15781
|
+
);
|
|
15558
15782
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15559
15783
|
return;
|
|
15560
15784
|
}
|
|
@@ -15803,7 +16027,7 @@ async function handleWait(run, step2, ctx) {
|
|
|
15803
16027
|
await ctx.queues.advance.add(
|
|
15804
16028
|
"advance",
|
|
15805
16029
|
{ flowRunId: String(run._id) },
|
|
15806
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
16030
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
15807
16031
|
);
|
|
15808
16032
|
}
|
|
15809
16033
|
async function handleCondition(run, step2, contact, ctx) {
|
|
@@ -15915,7 +16139,7 @@ async function handleWebhookStep(run, step2, ctx) {
|
|
|
15915
16139
|
await ctx.queues.advance.add(
|
|
15916
16140
|
"advance",
|
|
15917
16141
|
{ flowRunId: String(run._id) },
|
|
15918
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
16142
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
15919
16143
|
);
|
|
15920
16144
|
}
|
|
15921
16145
|
}
|
|
@@ -15944,7 +16168,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
15944
16168
|
{ flowRunId: String(run._id) },
|
|
15945
16169
|
{
|
|
15946
16170
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15947
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
16171
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15948
16172
|
}
|
|
15949
16173
|
);
|
|
15950
16174
|
}
|
|
@@ -15999,13 +16223,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
15999
16223
|
let arr = steps;
|
|
16000
16224
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
16001
16225
|
const parentIndex = branchPath[i];
|
|
16002
|
-
const
|
|
16226
|
+
const branchKey2 = branchPath[i + 1];
|
|
16003
16227
|
const parent = arr[parentIndex];
|
|
16004
16228
|
if (!parent || parent.type !== "branch") return null;
|
|
16005
|
-
arr =
|
|
16229
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
16006
16230
|
}
|
|
16007
16231
|
return arr[currentStepIndex] ?? null;
|
|
16008
16232
|
}
|
|
16233
|
+
function branchKey(run) {
|
|
16234
|
+
return run.currentBranchPath.join("_");
|
|
16235
|
+
}
|
|
16009
16236
|
function unitToMs(value, unit) {
|
|
16010
16237
|
const m = 6e4;
|
|
16011
16238
|
switch (unit) {
|
|
@@ -16032,6 +16259,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16032
16259
|
}
|
|
16033
16260
|
}
|
|
16034
16261
|
}
|
|
16262
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16035
16263
|
async function processScheduledBroadcasts(ctx) {
|
|
16036
16264
|
const now = /* @__PURE__ */ new Date();
|
|
16037
16265
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -16042,15 +16270,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16042
16270
|
{ returnDocument: "after" }
|
|
16043
16271
|
);
|
|
16044
16272
|
if (!claimed) continue;
|
|
16045
|
-
|
|
16046
|
-
|
|
16047
|
-
|
|
16048
|
-
|
|
16049
|
-
|
|
16050
|
-
|
|
16051
|
-
|
|
16052
|
-
|
|
16273
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
16274
|
+
}
|
|
16275
|
+
}
|
|
16276
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
16277
|
+
if (ctx.config.queue.driver === "noop") {
|
|
16278
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16279
|
+
return;
|
|
16280
|
+
}
|
|
16281
|
+
await ctx.queues.advance.add(
|
|
16282
|
+
"advance",
|
|
16283
|
+
{ broadcastId: String(broadcast._id) },
|
|
16284
|
+
{
|
|
16285
|
+
attempts: 3,
|
|
16286
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
16287
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
16053
16288
|
}
|
|
16289
|
+
);
|
|
16290
|
+
}
|
|
16291
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
16292
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
16293
|
+
if (!broadcast) return;
|
|
16294
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16295
|
+
}
|
|
16296
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
16297
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16298
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16299
|
+
for (const b of stalled) {
|
|
16300
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16301
|
+
{ _id: b._id },
|
|
16302
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16303
|
+
);
|
|
16304
|
+
await startBroadcastDispatch(b, ctx);
|
|
16305
|
+
}
|
|
16306
|
+
}
|
|
16307
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
16308
|
+
try {
|
|
16309
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
16310
|
+
} catch (err) {
|
|
16311
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16312
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16313
|
+
{ _id: broadcast._id },
|
|
16314
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16315
|
+
);
|
|
16054
16316
|
}
|
|
16055
16317
|
}
|
|
16056
16318
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -16071,11 +16333,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16071
16333
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
16072
16334
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
16073
16335
|
for (; ; ) {
|
|
16336
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16337
|
+
{ _id: broadcast._id },
|
|
16338
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16339
|
+
);
|
|
16074
16340
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
16075
16341
|
if (page.contacts.length === 0) break;
|
|
16076
16342
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
16077
16343
|
if (eligible.length > 0) {
|
|
16078
16344
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16345
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16346
|
+
{ _id: broadcast._id },
|
|
16347
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16348
|
+
);
|
|
16079
16349
|
await sleep(2e3);
|
|
16080
16350
|
}
|
|
16081
16351
|
const sendDocs = await Promise.all(
|
|
@@ -16198,7 +16468,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16198
16468
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
16199
16469
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
16200
16470
|
if (respectTimezone && contact.timezone) {
|
|
16201
|
-
|
|
16471
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
16472
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
16202
16473
|
}
|
|
16203
16474
|
const doc = {
|
|
16204
16475
|
_id: sendId,
|
|
@@ -16237,7 +16508,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16237
16508
|
};
|
|
16238
16509
|
return { doc, delayMs };
|
|
16239
16510
|
}
|
|
16240
|
-
function
|
|
16511
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16512
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16241
16513
|
try {
|
|
16242
16514
|
const scheduled = new Date(scheduledMs);
|
|
16243
16515
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -16250,7 +16522,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
16250
16522
|
const utcMs = parse(utc);
|
|
16251
16523
|
const localMs = parse(local);
|
|
16252
16524
|
const offsetMs = utcMs - localMs;
|
|
16253
|
-
return offsetMs;
|
|
16525
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
16254
16526
|
} catch {
|
|
16255
16527
|
return 0;
|
|
16256
16528
|
}
|
|
@@ -16925,6 +17197,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
|
|
|
16925
17197
|
|
|
16926
17198
|
// src/server/runner/tick.ts
|
|
16927
17199
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
17200
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16928
17201
|
async function runTick(ctx) {
|
|
16929
17202
|
try {
|
|
16930
17203
|
await ctx.collections.health.updateOne(
|
|
@@ -16965,12 +17238,18 @@ async function runTick(ctx) {
|
|
|
16965
17238
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
16966
17239
|
console.error("mailery: broadcast dispatch failed", err);
|
|
16967
17240
|
});
|
|
17241
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17242
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17243
|
+
});
|
|
16968
17244
|
await evaluateHealth(ctx).catch((err) => {
|
|
16969
17245
|
console.error("mailery: health evaluation failed", err);
|
|
16970
17246
|
});
|
|
16971
17247
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
16972
17248
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
16973
17249
|
});
|
|
17250
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
17251
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
17252
|
+
});
|
|
16974
17253
|
await Promise.all([
|
|
16975
17254
|
runDnsblChecks(ctx).catch((err) => {
|
|
16976
17255
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -17041,140 +17320,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
17041
17320
|
await processScheduledBroadcasts(ctx);
|
|
17042
17321
|
}
|
|
17043
17322
|
|
|
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
17323
|
// src/server/mailer.ts
|
|
17179
17324
|
var Mailer = class _Mailer {
|
|
17180
17325
|
db;
|
|
@@ -17214,6 +17359,7 @@ var Mailer = class _Mailer {
|
|
|
17214
17359
|
* MAILER_MONGODB_URI — Mongo connection string (required)
|
|
17215
17360
|
* MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
|
|
17216
17361
|
* MAILER_REDIS_URL — Redis connection URL (required)
|
|
17362
|
+
* MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
|
|
17217
17363
|
* MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
|
|
17218
17364
|
* MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
|
|
17219
17365
|
* MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
|
|
@@ -17262,7 +17408,11 @@ var Mailer = class _Mailer {
|
|
|
17262
17408
|
}
|
|
17263
17409
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
17264
17410
|
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
17265
|
-
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
17411
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
17412
|
+
driver: "bull",
|
|
17413
|
+
redis: { url: required("MAILER_REDIS_URL") },
|
|
17414
|
+
prefix: env.MAILER_QUEUE_PREFIX
|
|
17415
|
+
};
|
|
17266
17416
|
return _Mailer.init({
|
|
17267
17417
|
db,
|
|
17268
17418
|
adapter,
|
|
@@ -17639,41 +17789,48 @@ var Mailer = class _Mailer {
|
|
|
17639
17789
|
if (existing) return { sendId: String(existing._id) };
|
|
17640
17790
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
17641
17791
|
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
|
-
|
|
17792
|
+
try {
|
|
17793
|
+
await this.collections.sends.insertOne({
|
|
17794
|
+
_id: sendId,
|
|
17795
|
+
dedupeKey,
|
|
17796
|
+
externalId: parsed.externalId,
|
|
17797
|
+
emailAtSend: contact.email,
|
|
17798
|
+
templateId: template._id,
|
|
17799
|
+
templateSlug: template.slug,
|
|
17800
|
+
flowRunId: null,
|
|
17801
|
+
broadcastId: null,
|
|
17802
|
+
manualSendBy: "sendOneOff",
|
|
17803
|
+
kind: template.kind,
|
|
17804
|
+
provider: providerName,
|
|
17805
|
+
providerMessageId: null,
|
|
17806
|
+
fromName: template.fromName,
|
|
17807
|
+
fromEmail: template.fromEmail,
|
|
17808
|
+
subject: template.subject,
|
|
17809
|
+
bodyHash: "",
|
|
17810
|
+
status: "queued",
|
|
17811
|
+
errorMessage: null,
|
|
17812
|
+
bounceType: null,
|
|
17813
|
+
bounceReason: null,
|
|
17814
|
+
links: [],
|
|
17815
|
+
vars: parsed.vars ?? {},
|
|
17816
|
+
openedAt: null,
|
|
17817
|
+
openCount: 0,
|
|
17818
|
+
firstClickAt: null,
|
|
17819
|
+
clickCount: 0,
|
|
17820
|
+
clickedLinks: [],
|
|
17821
|
+
unsubscribedAt: null,
|
|
17822
|
+
complainedAt: null,
|
|
17823
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
17824
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17825
|
+
sentAt: null,
|
|
17826
|
+
deliveredAt: null
|
|
17827
|
+
});
|
|
17828
|
+
} catch (err) {
|
|
17829
|
+
if (err?.code !== 11e3) throw err;
|
|
17830
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
17831
|
+
if (winner) return { sendId: String(winner._id) };
|
|
17832
|
+
throw err;
|
|
17833
|
+
}
|
|
17677
17834
|
await this.queues.send.add(
|
|
17678
17835
|
"send",
|
|
17679
17836
|
{ sendId: String(sendId) },
|
|
@@ -17717,7 +17874,12 @@ var Mailer = class _Mailer {
|
|
|
17717
17874
|
await runTick(this.runnerContext);
|
|
17718
17875
|
},
|
|
17719
17876
|
advance: async (data) => {
|
|
17720
|
-
if (
|
|
17877
|
+
if (data.broadcastId) {
|
|
17878
|
+
if (!ObjectId.isValid(data.broadcastId)) return;
|
|
17879
|
+
await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
|
|
17880
|
+
return;
|
|
17881
|
+
}
|
|
17882
|
+
if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
|
|
17721
17883
|
await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
|
|
17722
17884
|
},
|
|
17723
17885
|
send: async (data) => {
|
|
@@ -17733,27 +17895,7 @@ var Mailer = class _Mailer {
|
|
|
17733
17895
|
}
|
|
17734
17896
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
17735
17897
|
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
|
-
}
|
|
17898
|
+
await processWebhookBacklog(this.runnerContext);
|
|
17757
17899
|
}
|
|
17758
17900
|
async stop() {
|
|
17759
17901
|
await this.queueDriver.close();
|