mailery 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/spa/{index-CjQTOX9H.js → index-B3ndbezm.js} +3 -3
- package/dist/admin/spa/index-B3ndbezm.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-yH0Oz4Ix.js → template-editor-DwMKy5sw.js} +3 -3
- package/dist/admin/spa/{template-editor-yH0Oz4Ix.js.map → template-editor-DwMKy5sw.js.map} +1 -1
- package/dist/index.cjs +807 -664
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +807 -664
- package/dist/index.js.map +1 -1
- package/dist/{null-B0rPgE5_.d.cts → null-CwXmiVLe.d.cts} +15 -1
- package/dist/{null-B0rPgE5_.d.ts → null-CwXmiVLe.d.ts} +15 -1
- package/dist/testing.cjs +875 -482
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +233 -13
- package/dist/testing.d.ts +233 -13
- package/dist/testing.js +869 -483
- package/dist/testing.js.map +1 -1
- package/package.json +5 -2
- package/dist/admin/spa/index-CjQTOX9H.js.map +0 -1
package/dist/testing.js
CHANGED
|
@@ -491,22 +491,22 @@ function __awaiter(thisArg, _arguments, P, generator) {
|
|
|
491
491
|
return new (P || (P = Promise))(function(resolve, reject) {
|
|
492
492
|
function fulfilled(value) {
|
|
493
493
|
try {
|
|
494
|
-
|
|
494
|
+
step2(generator.next(value));
|
|
495
495
|
} catch (e) {
|
|
496
496
|
reject(e);
|
|
497
497
|
}
|
|
498
498
|
}
|
|
499
499
|
function rejected(value) {
|
|
500
500
|
try {
|
|
501
|
-
|
|
501
|
+
step2(generator["throw"](value));
|
|
502
502
|
} catch (e) {
|
|
503
503
|
reject(e);
|
|
504
504
|
}
|
|
505
505
|
}
|
|
506
|
-
function
|
|
506
|
+
function step2(result) {
|
|
507
507
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
|
508
508
|
}
|
|
509
|
-
|
|
509
|
+
step2((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
510
510
|
});
|
|
511
511
|
}
|
|
512
512
|
function __generator(thisArg, body) {
|
|
@@ -519,10 +519,10 @@ function __generator(thisArg, body) {
|
|
|
519
519
|
}), g;
|
|
520
520
|
function verb(n) {
|
|
521
521
|
return function(v) {
|
|
522
|
-
return
|
|
522
|
+
return step2([n, v]);
|
|
523
523
|
};
|
|
524
524
|
}
|
|
525
|
-
function
|
|
525
|
+
function step2(op) {
|
|
526
526
|
if (f) throw new TypeError("Generator is already executing.");
|
|
527
527
|
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
528
528
|
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
@@ -656,12 +656,12 @@ function __asyncGenerator(thisArg, _arguments, generator) {
|
|
|
656
656
|
}
|
|
657
657
|
function resume(n, v) {
|
|
658
658
|
try {
|
|
659
|
-
|
|
659
|
+
step2(g[n](v));
|
|
660
660
|
} catch (e) {
|
|
661
661
|
settle(q[0][3], e);
|
|
662
662
|
}
|
|
663
663
|
}
|
|
664
|
-
function
|
|
664
|
+
function step2(r) {
|
|
665
665
|
r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r);
|
|
666
666
|
}
|
|
667
667
|
function fulfill(value) {
|
|
@@ -7079,9 +7079,9 @@ var require_pack = __commonJS({
|
|
|
7079
7079
|
this.push(headers.encode(newHeader));
|
|
7080
7080
|
}
|
|
7081
7081
|
_doDrain() {
|
|
7082
|
-
const
|
|
7082
|
+
const drain2 = this._drain;
|
|
7083
7083
|
this._drain = noop;
|
|
7084
|
-
|
|
7084
|
+
drain2();
|
|
7085
7085
|
}
|
|
7086
7086
|
_predestroy() {
|
|
7087
7087
|
const err = getStreamError(this);
|
|
@@ -14434,6 +14434,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14434
14434
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
14435
14435
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
14436
14436
|
{ key: { name: 1, occurredAt: -1 } },
|
|
14437
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
14437
14438
|
{ key: { externalId: 1, name: 1 } }
|
|
14438
14439
|
]),
|
|
14439
14440
|
c.flows.createIndexes([
|
|
@@ -14444,7 +14445,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14444
14445
|
c.flowRuns.createIndexes([
|
|
14445
14446
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
14446
14447
|
{ key: { externalId: 1, flowId: 1 } },
|
|
14447
|
-
{ key: { flowId: 1, status: 1 } }
|
|
14448
|
+
{ key: { flowId: 1, status: 1 } },
|
|
14449
|
+
{
|
|
14450
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
14451
|
+
unique: true,
|
|
14452
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
14453
|
+
}
|
|
14448
14454
|
]),
|
|
14449
14455
|
c.templates.createIndexes([
|
|
14450
14456
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -14632,7 +14638,13 @@ var BullDriver = class _BullDriver {
|
|
|
14632
14638
|
constructor(bull, redis) {
|
|
14633
14639
|
this.bull = bull;
|
|
14634
14640
|
this.redis = redis;
|
|
14635
|
-
const opts = {
|
|
14641
|
+
const opts = {
|
|
14642
|
+
connection: redis,
|
|
14643
|
+
defaultJobOptions: {
|
|
14644
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
14645
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
14646
|
+
}
|
|
14647
|
+
};
|
|
14636
14648
|
this.bullQueues = {
|
|
14637
14649
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
14638
14650
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -14650,7 +14662,11 @@ var BullDriver = class _BullDriver {
|
|
|
14650
14662
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
14651
14663
|
"mailer-tick-repeat",
|
|
14652
14664
|
{ every: intervalSeconds * 1e3 },
|
|
14653
|
-
{
|
|
14665
|
+
{
|
|
14666
|
+
name: "tick",
|
|
14667
|
+
data: {},
|
|
14668
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
14669
|
+
}
|
|
14654
14670
|
);
|
|
14655
14671
|
}
|
|
14656
14672
|
async startWorkers(opts) {
|
|
@@ -14760,9 +14776,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14760
14776
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
14761
14777
|
);
|
|
14762
14778
|
}
|
|
14779
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
14763
14780
|
const backend = new backendMod.MongoBackend({
|
|
14764
14781
|
mongo: opts.db,
|
|
14765
|
-
collection:
|
|
14782
|
+
collection: collectionName
|
|
14766
14783
|
});
|
|
14767
14784
|
const agenda = new agendaMod.Agenda({
|
|
14768
14785
|
backend,
|
|
@@ -14771,13 +14788,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14771
14788
|
maxConcurrency: 50,
|
|
14772
14789
|
defaultConcurrency: 5
|
|
14773
14790
|
});
|
|
14774
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
14791
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
14775
14792
|
}
|
|
14776
14793
|
db;
|
|
14777
|
-
|
|
14794
|
+
collName;
|
|
14795
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
14778
14796
|
this.agenda = agenda;
|
|
14779
14797
|
this.agendaMod = agendaMod;
|
|
14780
14798
|
this.db = db;
|
|
14799
|
+
this.collName = collectionName;
|
|
14781
14800
|
this.queues = {
|
|
14782
14801
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
14783
14802
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -14810,10 +14829,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14810
14829
|
}
|
|
14811
14830
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
14812
14831
|
jobsCollection() {
|
|
14813
|
-
return this.db.collection(this.
|
|
14814
|
-
}
|
|
14815
|
-
collectionName() {
|
|
14816
|
-
return "_mailerJobs";
|
|
14832
|
+
return this.db.collection(this.collName);
|
|
14817
14833
|
}
|
|
14818
14834
|
async findPending(name, jobId) {
|
|
14819
14835
|
return this.jobsCollection().findOne({
|
|
@@ -14823,12 +14839,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14823
14839
|
});
|
|
14824
14840
|
}
|
|
14825
14841
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
14826
|
-
if (!this.started) {
|
|
14827
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
14828
|
-
}, { concurrency: 1 });
|
|
14829
|
-
await this.agenda.start();
|
|
14830
|
-
this.started = true;
|
|
14831
|
-
}
|
|
14832
14842
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
14833
14843
|
}
|
|
14834
14844
|
async startWorkers(opts) {
|
|
@@ -14931,6 +14941,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
14931
14941
|
|
|
14932
14942
|
// src/server/runner/triggers.ts
|
|
14933
14943
|
var BATCH_SIZE = 1e3;
|
|
14944
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
14934
14945
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
14935
14946
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
14936
14947
|
for (const flow of flows) {
|
|
@@ -14941,16 +14952,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
14941
14952
|
const eventName = flow.trigger.eventName;
|
|
14942
14953
|
if (!eventName) return;
|
|
14943
14954
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
14944
|
-
const
|
|
14955
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
14956
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
14945
14957
|
if (events.length === 0) return;
|
|
14946
14958
|
for (const event of events) {
|
|
14947
14959
|
await tryEnterFlow(flow, event, ctx);
|
|
14948
14960
|
}
|
|
14949
|
-
const
|
|
14950
|
-
|
|
14951
|
-
|
|
14952
|
-
|
|
14953
|
-
|
|
14961
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
14962
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
14963
|
+
await ctx.collections.flows.updateOne(
|
|
14964
|
+
{ _id: flow._id },
|
|
14965
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
14966
|
+
);
|
|
14967
|
+
}
|
|
14954
14968
|
}
|
|
14955
14969
|
async function tryEnterFlow(flow, event, ctx) {
|
|
14956
14970
|
if (flow.trigger.once) {
|
|
@@ -14962,28 +14976,364 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14962
14976
|
}
|
|
14963
14977
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
14964
14978
|
if (!sub || sub.status !== "subscribed") return;
|
|
14965
|
-
|
|
14966
|
-
|
|
14967
|
-
|
|
14968
|
-
|
|
14969
|
-
|
|
14970
|
-
|
|
14971
|
-
|
|
14972
|
-
|
|
14973
|
-
|
|
14974
|
-
|
|
14975
|
-
|
|
14976
|
-
|
|
14977
|
-
|
|
14978
|
-
|
|
14979
|
-
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14979
|
+
let result;
|
|
14980
|
+
try {
|
|
14981
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
14982
|
+
externalId: event.externalId,
|
|
14983
|
+
flowId: flow._id,
|
|
14984
|
+
flowSlug: flow.slug,
|
|
14985
|
+
flowVersion: flow.version,
|
|
14986
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
14987
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
14988
|
+
triggerDedupeKey: event.dedupeKey,
|
|
14989
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
14990
|
+
status: "active",
|
|
14991
|
+
currentStepIndex: 0,
|
|
14992
|
+
currentBranchPath: [],
|
|
14993
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
14994
|
+
attemptsForCurrentStep: 0,
|
|
14995
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
14996
|
+
exitedAt: null,
|
|
14997
|
+
exitReason: null,
|
|
14998
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
14999
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15000
|
+
});
|
|
15001
|
+
} catch (err) {
|
|
15002
|
+
if (err?.code !== 11e3) throw err;
|
|
15003
|
+
return;
|
|
15004
|
+
}
|
|
14984
15005
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
14985
15006
|
}
|
|
14986
15007
|
|
|
15008
|
+
// src/server/templates/sender-domain.ts
|
|
15009
|
+
function extractDomain(email) {
|
|
15010
|
+
if (typeof email !== "string") return null;
|
|
15011
|
+
const at = email.lastIndexOf("@");
|
|
15012
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15013
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15014
|
+
}
|
|
15015
|
+
|
|
15016
|
+
// src/server/runner/health.ts
|
|
15017
|
+
var ZERO_COUNTERS = {
|
|
15018
|
+
sent: 0,
|
|
15019
|
+
delivered: 0,
|
|
15020
|
+
bounced: 0,
|
|
15021
|
+
hardBounced: 0,
|
|
15022
|
+
softBounced: 0,
|
|
15023
|
+
complained: 0,
|
|
15024
|
+
failedToSend: 0
|
|
15025
|
+
};
|
|
15026
|
+
var ZERO_RATES = {
|
|
15027
|
+
bounceRate: 0,
|
|
15028
|
+
hardBounceRate: 0,
|
|
15029
|
+
complaintRate: 0,
|
|
15030
|
+
failureRate: 0
|
|
15031
|
+
};
|
|
15032
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15033
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15034
|
+
const writes = [];
|
|
15035
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15036
|
+
if (dims) {
|
|
15037
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15038
|
+
if (domain) {
|
|
15039
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15040
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15041
|
+
}
|
|
15042
|
+
}
|
|
15043
|
+
await Promise.all(writes);
|
|
15044
|
+
}
|
|
15045
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15046
|
+
await ctx.collections.health.updateOne(
|
|
15047
|
+
{ _id },
|
|
15048
|
+
{
|
|
15049
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
15050
|
+
$setOnInsert: {
|
|
15051
|
+
_id,
|
|
15052
|
+
senderDomain,
|
|
15053
|
+
kind,
|
|
15054
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15055
|
+
windowDurationMs: windowMs,
|
|
15056
|
+
status: "healthy",
|
|
15057
|
+
trippedAt: null,
|
|
15058
|
+
trippedReason: null,
|
|
15059
|
+
manuallyResumedAt: null,
|
|
15060
|
+
rates: { ...ZERO_RATES }
|
|
15061
|
+
},
|
|
15062
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15063
|
+
},
|
|
15064
|
+
{ upsert: true }
|
|
15065
|
+
);
|
|
15066
|
+
}
|
|
15067
|
+
async function evaluateHealth(ctx) {
|
|
15068
|
+
const cb = ctx.config.circuitBreaker;
|
|
15069
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15070
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15071
|
+
if (docs.length === 0) return;
|
|
15072
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15073
|
+
if (!hasAgg) {
|
|
15074
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15075
|
+
}
|
|
15076
|
+
for (const doc of docs) {
|
|
15077
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15078
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15079
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15080
|
+
await ctx.collections.health.updateOne(
|
|
15081
|
+
{ _id: doc._id },
|
|
15082
|
+
{
|
|
15083
|
+
$set: {
|
|
15084
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15085
|
+
windowDurationMs: windowMs,
|
|
15086
|
+
counters: { ...ZERO_COUNTERS },
|
|
15087
|
+
rates: { ...ZERO_RATES },
|
|
15088
|
+
status: "healthy",
|
|
15089
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15090
|
+
}
|
|
15091
|
+
}
|
|
15092
|
+
);
|
|
15093
|
+
continue;
|
|
15094
|
+
}
|
|
15095
|
+
const c = doc.counters;
|
|
15096
|
+
const total = c.sent || 1;
|
|
15097
|
+
const rates = {
|
|
15098
|
+
bounceRate: c.bounced / total,
|
|
15099
|
+
hardBounceRate: c.hardBounced / total,
|
|
15100
|
+
complaintRate: c.complained / total,
|
|
15101
|
+
failureRate: c.failedToSend / total
|
|
15102
|
+
};
|
|
15103
|
+
await ctx.collections.health.updateOne(
|
|
15104
|
+
{ _id: doc._id },
|
|
15105
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15106
|
+
);
|
|
15107
|
+
if (isAgg) continue;
|
|
15108
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15109
|
+
if (doc.status === "tripped") continue;
|
|
15110
|
+
let trippedReason = null;
|
|
15111
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15112
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15113
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15114
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15115
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15116
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15117
|
+
}
|
|
15118
|
+
if (trippedReason) {
|
|
15119
|
+
const result = await ctx.collections.health.updateOne(
|
|
15120
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15121
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15122
|
+
);
|
|
15123
|
+
if (result.modifiedCount > 0) {
|
|
15124
|
+
if (ctx.audit) {
|
|
15125
|
+
try {
|
|
15126
|
+
await ctx.audit({
|
|
15127
|
+
actor: "system:circuit-breaker",
|
|
15128
|
+
action: "health.trip",
|
|
15129
|
+
resource: {
|
|
15130
|
+
collection: "mailer_health",
|
|
15131
|
+
id: String(doc._id),
|
|
15132
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15133
|
+
},
|
|
15134
|
+
diffSummary: trippedReason
|
|
15135
|
+
});
|
|
15136
|
+
} catch {
|
|
15137
|
+
}
|
|
15138
|
+
}
|
|
15139
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15140
|
+
try {
|
|
15141
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15142
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15143
|
+
rates
|
|
15144
|
+
});
|
|
15145
|
+
} catch {
|
|
15146
|
+
}
|
|
15147
|
+
}
|
|
15148
|
+
}
|
|
15149
|
+
continue;
|
|
15150
|
+
}
|
|
15151
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15152
|
+
if (doc.status !== "degraded") {
|
|
15153
|
+
await ctx.collections.health.updateOne(
|
|
15154
|
+
{ _id: doc._id },
|
|
15155
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15156
|
+
);
|
|
15157
|
+
}
|
|
15158
|
+
} else if (doc.status === "degraded") {
|
|
15159
|
+
await ctx.collections.health.updateOne(
|
|
15160
|
+
{ _id: doc._id },
|
|
15161
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15162
|
+
);
|
|
15163
|
+
}
|
|
15164
|
+
}
|
|
15165
|
+
}
|
|
15166
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15167
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15168
|
+
const id = healthBucketId(domain, kind);
|
|
15169
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15170
|
+
}
|
|
15171
|
+
|
|
15172
|
+
// src/server/runner/webhook.ts
|
|
15173
|
+
function dimsFromSend(send) {
|
|
15174
|
+
if (!send) return null;
|
|
15175
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
15176
|
+
}
|
|
15177
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
15178
|
+
const filter = { processed: false };
|
|
15179
|
+
if (opts.olderThanMs) {
|
|
15180
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
15181
|
+
}
|
|
15182
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
15183
|
+
for (const evt of batch) {
|
|
15184
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
15185
|
+
{ _id: evt._id, processed: false },
|
|
15186
|
+
{ $set: { processed: true } }
|
|
15187
|
+
);
|
|
15188
|
+
if (!claimed) continue;
|
|
15189
|
+
try {
|
|
15190
|
+
const normalized = evt.raw?.normalized;
|
|
15191
|
+
const details = normalized?.details ?? {};
|
|
15192
|
+
await applyWebhookEvent(
|
|
15193
|
+
{
|
|
15194
|
+
type: evt.normalizedType,
|
|
15195
|
+
providerEventId: evt.providerEventId,
|
|
15196
|
+
providerMessageId: evt.providerMessageId,
|
|
15197
|
+
email: evt.email,
|
|
15198
|
+
occurredAt: evt.occurredAt,
|
|
15199
|
+
details
|
|
15200
|
+
},
|
|
15201
|
+
ctx
|
|
15202
|
+
);
|
|
15203
|
+
} catch (err) {
|
|
15204
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
15205
|
+
}
|
|
15206
|
+
}
|
|
15207
|
+
}
|
|
15208
|
+
async function applyWebhookEvent(event, ctx) {
|
|
15209
|
+
const send = await ctx.collections.sends.findOne(
|
|
15210
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
15211
|
+
{ sort: { queuedAt: -1 } }
|
|
15212
|
+
);
|
|
15213
|
+
switch (event.type) {
|
|
15214
|
+
case "delivered":
|
|
15215
|
+
if (send) {
|
|
15216
|
+
await ctx.collections.sends.updateOne(
|
|
15217
|
+
{ _id: send._id },
|
|
15218
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
15219
|
+
);
|
|
15220
|
+
}
|
|
15221
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
15222
|
+
break;
|
|
15223
|
+
case "open":
|
|
15224
|
+
if (send) {
|
|
15225
|
+
await ctx.collections.sends.updateOne(
|
|
15226
|
+
{ _id: send._id },
|
|
15227
|
+
{
|
|
15228
|
+
$set: {
|
|
15229
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
15230
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
15231
|
+
},
|
|
15232
|
+
$inc: { openCount: 1 }
|
|
15233
|
+
}
|
|
15234
|
+
);
|
|
15235
|
+
}
|
|
15236
|
+
break;
|
|
15237
|
+
case "click":
|
|
15238
|
+
if (send) {
|
|
15239
|
+
await ctx.collections.sends.updateOne(
|
|
15240
|
+
{ _id: send._id },
|
|
15241
|
+
{
|
|
15242
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
15243
|
+
$inc: { clickCount: 1 },
|
|
15244
|
+
$push: {
|
|
15245
|
+
clickedLinks: {
|
|
15246
|
+
url: event.details.clickedUrl ?? "",
|
|
15247
|
+
linkId: "",
|
|
15248
|
+
clickedAt: event.occurredAt
|
|
15249
|
+
}
|
|
15250
|
+
}
|
|
15251
|
+
}
|
|
15252
|
+
);
|
|
15253
|
+
}
|
|
15254
|
+
break;
|
|
15255
|
+
case "bounce": {
|
|
15256
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
15257
|
+
if (send) {
|
|
15258
|
+
await ctx.collections.sends.updateOne(
|
|
15259
|
+
{ _id: send._id },
|
|
15260
|
+
{
|
|
15261
|
+
$set: {
|
|
15262
|
+
status: "bounced",
|
|
15263
|
+
bounceType,
|
|
15264
|
+
bounceReason: event.details.bounceReason ?? null
|
|
15265
|
+
}
|
|
15266
|
+
}
|
|
15267
|
+
);
|
|
15268
|
+
}
|
|
15269
|
+
if (bounceType === "hard") {
|
|
15270
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
15271
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15272
|
+
{ emailAtSubscribe: event.email },
|
|
15273
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15274
|
+
);
|
|
15275
|
+
}
|
|
15276
|
+
{
|
|
15277
|
+
const dims = dimsFromSend(send);
|
|
15278
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
15279
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
15280
|
+
}
|
|
15281
|
+
break;
|
|
15282
|
+
}
|
|
15283
|
+
case "complaint":
|
|
15284
|
+
case "spam_report":
|
|
15285
|
+
if (send) {
|
|
15286
|
+
await ctx.collections.sends.updateOne(
|
|
15287
|
+
{ _id: send._id },
|
|
15288
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
15289
|
+
);
|
|
15290
|
+
}
|
|
15291
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
15292
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15293
|
+
{ emailAtSubscribe: event.email },
|
|
15294
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15295
|
+
);
|
|
15296
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
15297
|
+
break;
|
|
15298
|
+
case "unsubscribe":
|
|
15299
|
+
if (send) {
|
|
15300
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
15301
|
+
}
|
|
15302
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
15303
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15304
|
+
{ emailAtSubscribe: event.email },
|
|
15305
|
+
{
|
|
15306
|
+
$set: {
|
|
15307
|
+
status: "unsubscribed",
|
|
15308
|
+
unsubscribedAt: event.occurredAt,
|
|
15309
|
+
unsubscribeReason: "user_request",
|
|
15310
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15311
|
+
}
|
|
15312
|
+
}
|
|
15313
|
+
);
|
|
15314
|
+
break;
|
|
15315
|
+
}
|
|
15316
|
+
}
|
|
15317
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
15318
|
+
const normalized = email.toLowerCase();
|
|
15319
|
+
await ctx.collections.suppressions.updateOne(
|
|
15320
|
+
{ email: normalized, scope },
|
|
15321
|
+
{
|
|
15322
|
+
$setOnInsert: {
|
|
15323
|
+
email: normalized,
|
|
15324
|
+
emailHash: sha256Hex(normalized),
|
|
15325
|
+
scope,
|
|
15326
|
+
reason,
|
|
15327
|
+
source: "provider_webhook",
|
|
15328
|
+
notes: null,
|
|
15329
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
15330
|
+
expiresAt: null
|
|
15331
|
+
}
|
|
15332
|
+
},
|
|
15333
|
+
{ upsert: true }
|
|
15334
|
+
);
|
|
15335
|
+
}
|
|
15336
|
+
|
|
14987
15337
|
// src/server/runner/predicate.ts
|
|
14988
15338
|
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
14989
15339
|
async function evaluatePredicate(predicate, ctx) {
|
|
@@ -15207,7 +15557,8 @@ function applyTracking(html, opts) {
|
|
|
15207
15557
|
const seen = /* @__PURE__ */ new Map();
|
|
15208
15558
|
let out = html;
|
|
15209
15559
|
if (opts.trackClicks) {
|
|
15210
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
15560
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
15561
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
15211
15562
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
15212
15563
|
let linkId = seen.get(url);
|
|
15213
15564
|
if (!linkId) {
|
|
@@ -15239,6 +15590,29 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
15239
15590
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
15240
15591
|
return false;
|
|
15241
15592
|
}
|
|
15593
|
+
var NAMED_ENTITIES = {
|
|
15594
|
+
amp: "&",
|
|
15595
|
+
lt: "<",
|
|
15596
|
+
gt: ">",
|
|
15597
|
+
quot: '"',
|
|
15598
|
+
apos: "'",
|
|
15599
|
+
nbsp: "\xA0"
|
|
15600
|
+
};
|
|
15601
|
+
function decodeHtmlEntities(input) {
|
|
15602
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
15603
|
+
if (body[0] === "#") {
|
|
15604
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
15605
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
15606
|
+
try {
|
|
15607
|
+
return String.fromCodePoint(code);
|
|
15608
|
+
} catch {
|
|
15609
|
+
return match;
|
|
15610
|
+
}
|
|
15611
|
+
}
|
|
15612
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
15613
|
+
return named ?? match;
|
|
15614
|
+
});
|
|
15615
|
+
}
|
|
15242
15616
|
function shortHash(input) {
|
|
15243
15617
|
return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
15244
15618
|
}
|
|
@@ -15307,175 +15681,11 @@ async function isSuppressed(collections, email, kind) {
|
|
|
15307
15681
|
return { suppressed: false };
|
|
15308
15682
|
}
|
|
15309
15683
|
|
|
15310
|
-
// src/server/templates/sender-domain.ts
|
|
15311
|
-
function extractDomain(email) {
|
|
15312
|
-
if (typeof email !== "string") return null;
|
|
15313
|
-
const at = email.lastIndexOf("@");
|
|
15314
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
15315
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
15316
|
-
}
|
|
15317
|
-
|
|
15318
|
-
// src/server/runner/health.ts
|
|
15319
|
-
var ZERO_COUNTERS = {
|
|
15320
|
-
sent: 0,
|
|
15321
|
-
delivered: 0,
|
|
15322
|
-
bounced: 0,
|
|
15323
|
-
hardBounced: 0,
|
|
15324
|
-
softBounced: 0,
|
|
15325
|
-
complained: 0,
|
|
15326
|
-
failedToSend: 0
|
|
15327
|
-
};
|
|
15328
|
-
var ZERO_RATES = {
|
|
15329
|
-
bounceRate: 0,
|
|
15330
|
-
hardBounceRate: 0,
|
|
15331
|
-
complaintRate: 0,
|
|
15332
|
-
failureRate: 0
|
|
15333
|
-
};
|
|
15334
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15335
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15336
|
-
const writes = [];
|
|
15337
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15338
|
-
if (dims) {
|
|
15339
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15340
|
-
if (domain) {
|
|
15341
|
-
const id = healthBucketId(domain, dims.kind);
|
|
15342
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15343
|
-
}
|
|
15344
|
-
}
|
|
15345
|
-
await Promise.all(writes);
|
|
15346
|
-
}
|
|
15347
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15348
|
-
await ctx.collections.health.updateOne(
|
|
15349
|
-
{ _id },
|
|
15350
|
-
{
|
|
15351
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
15352
|
-
$setOnInsert: {
|
|
15353
|
-
_id,
|
|
15354
|
-
senderDomain,
|
|
15355
|
-
kind,
|
|
15356
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15357
|
-
windowDurationMs: windowMs,
|
|
15358
|
-
status: "healthy",
|
|
15359
|
-
trippedAt: null,
|
|
15360
|
-
trippedReason: null,
|
|
15361
|
-
manuallyResumedAt: null,
|
|
15362
|
-
rates: { ...ZERO_RATES }
|
|
15363
|
-
},
|
|
15364
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15365
|
-
},
|
|
15366
|
-
{ upsert: true }
|
|
15367
|
-
);
|
|
15368
|
-
}
|
|
15369
|
-
async function evaluateHealth(ctx) {
|
|
15370
|
-
const cb = ctx.config.circuitBreaker;
|
|
15371
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15372
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
15373
|
-
if (docs.length === 0) return;
|
|
15374
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15375
|
-
if (!hasAgg) {
|
|
15376
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15377
|
-
}
|
|
15378
|
-
for (const doc of docs) {
|
|
15379
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15380
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15381
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15382
|
-
await ctx.collections.health.updateOne(
|
|
15383
|
-
{ _id: doc._id },
|
|
15384
|
-
{
|
|
15385
|
-
$set: {
|
|
15386
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15387
|
-
windowDurationMs: windowMs,
|
|
15388
|
-
counters: { ...ZERO_COUNTERS },
|
|
15389
|
-
rates: { ...ZERO_RATES },
|
|
15390
|
-
status: "healthy",
|
|
15391
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
15392
|
-
}
|
|
15393
|
-
}
|
|
15394
|
-
);
|
|
15395
|
-
continue;
|
|
15396
|
-
}
|
|
15397
|
-
const c = doc.counters;
|
|
15398
|
-
const total = c.sent || 1;
|
|
15399
|
-
const rates = {
|
|
15400
|
-
bounceRate: c.bounced / total,
|
|
15401
|
-
hardBounceRate: c.hardBounced / total,
|
|
15402
|
-
complaintRate: c.complained / total,
|
|
15403
|
-
failureRate: c.failedToSend / total
|
|
15404
|
-
};
|
|
15405
|
-
await ctx.collections.health.updateOne(
|
|
15406
|
-
{ _id: doc._id },
|
|
15407
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15408
|
-
);
|
|
15409
|
-
if (isAgg) continue;
|
|
15410
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15411
|
-
if (doc.status === "tripped") continue;
|
|
15412
|
-
let trippedReason = null;
|
|
15413
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15414
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15415
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15416
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15417
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15418
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15419
|
-
}
|
|
15420
|
-
if (trippedReason) {
|
|
15421
|
-
const result = await ctx.collections.health.updateOne(
|
|
15422
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15423
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15424
|
-
);
|
|
15425
|
-
if (result.modifiedCount > 0) {
|
|
15426
|
-
if (ctx.audit) {
|
|
15427
|
-
try {
|
|
15428
|
-
await ctx.audit({
|
|
15429
|
-
actor: "system:circuit-breaker",
|
|
15430
|
-
action: "health.trip",
|
|
15431
|
-
resource: {
|
|
15432
|
-
collection: "mailer_health",
|
|
15433
|
-
id: String(doc._id),
|
|
15434
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15435
|
-
},
|
|
15436
|
-
diffSummary: trippedReason
|
|
15437
|
-
});
|
|
15438
|
-
} catch {
|
|
15439
|
-
}
|
|
15440
|
-
}
|
|
15441
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
15442
|
-
try {
|
|
15443
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
15444
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15445
|
-
rates
|
|
15446
|
-
});
|
|
15447
|
-
} catch {
|
|
15448
|
-
}
|
|
15449
|
-
}
|
|
15450
|
-
}
|
|
15451
|
-
continue;
|
|
15452
|
-
}
|
|
15453
|
-
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15454
|
-
if (doc.status !== "degraded") {
|
|
15455
|
-
await ctx.collections.health.updateOne(
|
|
15456
|
-
{ _id: doc._id },
|
|
15457
|
-
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15458
|
-
);
|
|
15459
|
-
}
|
|
15460
|
-
} else if (doc.status === "degraded") {
|
|
15461
|
-
await ctx.collections.health.updateOne(
|
|
15462
|
-
{ _id: doc._id },
|
|
15463
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15464
|
-
);
|
|
15465
|
-
}
|
|
15466
|
-
}
|
|
15467
|
-
}
|
|
15468
|
-
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15469
|
-
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15470
|
-
const id = healthBucketId(domain, kind);
|
|
15471
|
-
return ctx.collections.health.findOne({ _id: id });
|
|
15472
|
-
}
|
|
15473
|
-
|
|
15474
15684
|
// src/server/runner/send.ts
|
|
15475
|
-
async function handleSend(run,
|
|
15476
|
-
const template = await ctx.collections.templates.findOne({ slug:
|
|
15685
|
+
async function handleSend(run, step2, contact, flow, ctx) {
|
|
15686
|
+
const template = await ctx.collections.templates.findOne({ slug: step2.templateSlug });
|
|
15477
15687
|
if (!template) {
|
|
15478
|
-
await failFlowRun(run, `template not found: ${
|
|
15688
|
+
await failFlowRun(run, `template not found: ${step2.templateSlug}`, ctx);
|
|
15479
15689
|
return;
|
|
15480
15690
|
}
|
|
15481
15691
|
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}`;
|
|
@@ -15487,8 +15697,8 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15487
15697
|
});
|
|
15488
15698
|
return;
|
|
15489
15699
|
}
|
|
15490
|
-
const providerName = pickProviderName(
|
|
15491
|
-
const renderCtx = buildRenderContext(contact, run,
|
|
15700
|
+
const providerName = pickProviderName(step2.providerOverride, template, ctx);
|
|
15701
|
+
const renderCtx = buildRenderContext(contact, run, step2.vars ?? {}, ctx);
|
|
15492
15702
|
let rendered;
|
|
15493
15703
|
try {
|
|
15494
15704
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
@@ -15525,7 +15735,7 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15525
15735
|
bodyHash: sha256(rendered.html),
|
|
15526
15736
|
status: "queued",
|
|
15527
15737
|
renderedFrom: rendered.fromName ? { name: rendered.fromName, email: rendered.fromEmail } : void 0,
|
|
15528
|
-
vars:
|
|
15738
|
+
vars: step2.vars ?? {}
|
|
15529
15739
|
})
|
|
15530
15740
|
);
|
|
15531
15741
|
await ctx.queues.send.add(
|
|
@@ -15536,9 +15746,11 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15536
15746
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
15537
15747
|
}
|
|
15538
15748
|
async function dispatchSend(sendId, ctx) {
|
|
15539
|
-
const send = await ctx.collections.sends.
|
|
15749
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
15750
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
15751
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15752
|
+
);
|
|
15540
15753
|
if (!send) return;
|
|
15541
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
15542
15754
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
15543
15755
|
if (!template) {
|
|
15544
15756
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -15555,6 +15767,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15555
15767
|
if (send.kind === "marketing") {
|
|
15556
15768
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15557
15769
|
if (bucket?.status === "tripped") {
|
|
15770
|
+
await ctx.collections.sends.updateOne(
|
|
15771
|
+
{ _id: send._id },
|
|
15772
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15773
|
+
);
|
|
15558
15774
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15559
15775
|
return;
|
|
15560
15776
|
}
|
|
@@ -15614,6 +15830,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15614
15830
|
await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
|
|
15615
15831
|
return;
|
|
15616
15832
|
}
|
|
15833
|
+
const headers = send.kind === "marketing" ? {
|
|
15834
|
+
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15835
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15836
|
+
} : {};
|
|
15617
15837
|
try {
|
|
15618
15838
|
const result = await provider.send({
|
|
15619
15839
|
to: send.emailAtSend,
|
|
@@ -15623,10 +15843,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15623
15843
|
subject: rendered.subject,
|
|
15624
15844
|
html: tracking.html,
|
|
15625
15845
|
text: rendered.plainText,
|
|
15626
|
-
headers
|
|
15627
|
-
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15628
|
-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15629
|
-
},
|
|
15846
|
+
headers,
|
|
15630
15847
|
messageMeta: { sendId: String(send._id) }
|
|
15631
15848
|
});
|
|
15632
15849
|
await ctx.collections.sends.updateOne(
|
|
@@ -15738,8 +15955,8 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15738
15955
|
return;
|
|
15739
15956
|
}
|
|
15740
15957
|
const steps = await loadStepsForRun(run, flow, ctx);
|
|
15741
|
-
const
|
|
15742
|
-
if (!
|
|
15958
|
+
const step2 = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
|
|
15959
|
+
if (!step2) {
|
|
15743
15960
|
await completeFlowRun(run, "completed", ctx);
|
|
15744
15961
|
return;
|
|
15745
15962
|
}
|
|
@@ -15753,34 +15970,34 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15753
15970
|
await exitFlowRun(run, sub.status, ctx);
|
|
15754
15971
|
return;
|
|
15755
15972
|
}
|
|
15756
|
-
switch (
|
|
15973
|
+
switch (step2.type) {
|
|
15757
15974
|
case "wait":
|
|
15758
|
-
return handleWait(run,
|
|
15975
|
+
return handleWait(run, step2, ctx);
|
|
15759
15976
|
case "condition":
|
|
15760
|
-
return handleCondition(run,
|
|
15977
|
+
return handleCondition(run, step2, contact, ctx);
|
|
15761
15978
|
case "branch":
|
|
15762
|
-
return handleBranch(run,
|
|
15979
|
+
return handleBranch(run, step2, contact, ctx);
|
|
15763
15980
|
case "send": {
|
|
15764
|
-
if (
|
|
15765
|
-
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(),
|
|
15981
|
+
if (step2.delivery) {
|
|
15982
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step2.delivery, contact.timezone);
|
|
15766
15983
|
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
15767
15984
|
return deferSendForWindow(run, deliverAt, ctx);
|
|
15768
15985
|
}
|
|
15769
15986
|
}
|
|
15770
|
-
return handleSend(run,
|
|
15987
|
+
return handleSend(run, step2, contact, flow, ctx);
|
|
15771
15988
|
}
|
|
15772
15989
|
case "tag":
|
|
15773
|
-
return handleTag(run,
|
|
15990
|
+
return handleTag(run, step2, ctx);
|
|
15774
15991
|
case "fire_event":
|
|
15775
|
-
return handleFireEvent(run,
|
|
15992
|
+
return handleFireEvent(run, step2, ctx);
|
|
15776
15993
|
case "webhook":
|
|
15777
|
-
return handleWebhookStep(run,
|
|
15994
|
+
return handleWebhookStep(run, step2, ctx);
|
|
15778
15995
|
case "exit":
|
|
15779
|
-
return exitFlowRun(run,
|
|
15996
|
+
return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
|
|
15780
15997
|
}
|
|
15781
15998
|
}
|
|
15782
|
-
async function handleWait(run,
|
|
15783
|
-
const ms = unitToMs(
|
|
15999
|
+
async function handleWait(run, step2, ctx) {
|
|
16000
|
+
const ms = unitToMs(step2.value, step2.unit);
|
|
15784
16001
|
const nextAt = new Date(Date.now() + ms);
|
|
15785
16002
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15786
16003
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15802,25 +16019,25 @@ async function handleWait(run, step, ctx) {
|
|
|
15802
16019
|
await ctx.queues.advance.add(
|
|
15803
16020
|
"advance",
|
|
15804
16021
|
{ flowRunId: String(run._id) },
|
|
15805
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
16022
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
15806
16023
|
);
|
|
15807
16024
|
}
|
|
15808
|
-
async function handleCondition(run,
|
|
16025
|
+
async function handleCondition(run, step2, contact, ctx) {
|
|
15809
16026
|
if (!contact) return;
|
|
15810
|
-
const result = await evaluatePredicate(
|
|
16027
|
+
const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
|
|
15811
16028
|
if (result) {
|
|
15812
16029
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
15813
16030
|
return;
|
|
15814
16031
|
}
|
|
15815
|
-
if (
|
|
16032
|
+
if (step2.ifFalse === "continue") {
|
|
15816
16033
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: false, skipped: 1 } }, { stepInc: 2 });
|
|
15817
16034
|
return;
|
|
15818
16035
|
}
|
|
15819
16036
|
await exitFlowRun(run, "condition_false", ctx);
|
|
15820
16037
|
}
|
|
15821
|
-
async function handleBranch(run,
|
|
16038
|
+
async function handleBranch(run, step2, contact, ctx) {
|
|
15822
16039
|
if (!contact) return;
|
|
15823
|
-
const result = await evaluatePredicate(
|
|
16040
|
+
const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
|
|
15824
16041
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
15825
16042
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15826
16043
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15846,9 +16063,9 @@ async function handleBranch(run, step, contact, ctx) {
|
|
|
15846
16063
|
if (!updated) return;
|
|
15847
16064
|
await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
|
|
15848
16065
|
}
|
|
15849
|
-
async function handleTag(run,
|
|
15850
|
-
const adds =
|
|
15851
|
-
const removes =
|
|
16066
|
+
async function handleTag(run, step2, ctx) {
|
|
16067
|
+
const adds = step2.addTags ?? [];
|
|
16068
|
+
const removes = step2.removeTags ?? [];
|
|
15852
16069
|
if (ctx.adapter.addTags && adds.length > 0) {
|
|
15853
16070
|
await ctx.adapter.addTags(run.externalId, adds);
|
|
15854
16071
|
} else if (adds.length > 0) {
|
|
@@ -15869,13 +16086,13 @@ async function handleTag(run, step, ctx) {
|
|
|
15869
16086
|
}
|
|
15870
16087
|
await advanceStep(run, ctx, { action: "tagged", details: { addTags: adds, removeTags: removes } });
|
|
15871
16088
|
}
|
|
15872
|
-
async function handleFireEvent(run,
|
|
15873
|
-
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${
|
|
16089
|
+
async function handleFireEvent(run, step2, ctx) {
|
|
16090
|
+
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step2.eventName}`;
|
|
15874
16091
|
try {
|
|
15875
16092
|
await ctx.collections.events.insertOne({
|
|
15876
16093
|
externalId: run.externalId,
|
|
15877
|
-
name:
|
|
15878
|
-
properties:
|
|
16094
|
+
name: step2.eventName,
|
|
16095
|
+
properties: step2.properties ?? {},
|
|
15879
16096
|
dedupeKey,
|
|
15880
16097
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
15881
16098
|
createdAt: /* @__PURE__ */ new Date()
|
|
@@ -15883,27 +16100,27 @@ async function handleFireEvent(run, step, ctx) {
|
|
|
15883
16100
|
} catch (err) {
|
|
15884
16101
|
if (err?.code !== 11e3) throw err;
|
|
15885
16102
|
}
|
|
15886
|
-
await advanceStep(run, ctx, { action: "event_fired", details: { name:
|
|
16103
|
+
await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
|
|
15887
16104
|
}
|
|
15888
|
-
async function handleWebhookStep(run,
|
|
16105
|
+
async function handleWebhookStep(run, step2, ctx) {
|
|
15889
16106
|
try {
|
|
15890
|
-
const res = await fetch(
|
|
15891
|
-
method:
|
|
16107
|
+
const res = await fetch(step2.url, {
|
|
16108
|
+
method: step2.method ?? "POST",
|
|
15892
16109
|
headers: { "Content-Type": "application/json" },
|
|
15893
|
-
body: JSON.stringify(
|
|
16110
|
+
body: JSON.stringify(step2.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
|
|
15894
16111
|
signal: AbortSignal.timeout(1e4)
|
|
15895
16112
|
});
|
|
15896
16113
|
if (!res.ok) throw new Error(`webhook step returned ${res.status}`);
|
|
15897
|
-
await advanceStep(run, ctx, { action: "webhook_called", details: { url:
|
|
16114
|
+
await advanceStep(run, ctx, { action: "webhook_called", details: { url: step2.url, status: res.status } });
|
|
15898
16115
|
} catch (err) {
|
|
15899
16116
|
const attempts = (run.attemptsForCurrentStep ?? 0) + 1;
|
|
15900
16117
|
if (attempts >= ctx.config.webhookRetryAttempts) {
|
|
15901
|
-
if (
|
|
16118
|
+
if (step2.failureMode === "fail_run") {
|
|
15902
16119
|
await failFlowRun(run, `webhook failed: ${err?.message}`, ctx);
|
|
15903
16120
|
} else {
|
|
15904
16121
|
await advanceStep(run, ctx, {
|
|
15905
16122
|
action: "webhook_called",
|
|
15906
|
-
details: { url:
|
|
16123
|
+
details: { url: step2.url, error: err?.message, exhausted: true }
|
|
15907
16124
|
});
|
|
15908
16125
|
}
|
|
15909
16126
|
} else {
|
|
@@ -15914,7 +16131,7 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
15914
16131
|
await ctx.queues.advance.add(
|
|
15915
16132
|
"advance",
|
|
15916
16133
|
{ flowRunId: String(run._id) },
|
|
15917
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
16134
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
15918
16135
|
);
|
|
15919
16136
|
}
|
|
15920
16137
|
}
|
|
@@ -15943,7 +16160,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
15943
16160
|
{ flowRunId: String(run._id) },
|
|
15944
16161
|
{
|
|
15945
16162
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15946
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
16163
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15947
16164
|
}
|
|
15948
16165
|
);
|
|
15949
16166
|
}
|
|
@@ -15998,13 +16215,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
15998
16215
|
let arr = steps;
|
|
15999
16216
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
16000
16217
|
const parentIndex = branchPath[i];
|
|
16001
|
-
const
|
|
16218
|
+
const branchKey2 = branchPath[i + 1];
|
|
16002
16219
|
const parent = arr[parentIndex];
|
|
16003
16220
|
if (!parent || parent.type !== "branch") return null;
|
|
16004
|
-
arr =
|
|
16221
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
16005
16222
|
}
|
|
16006
16223
|
return arr[currentStepIndex] ?? null;
|
|
16007
16224
|
}
|
|
16225
|
+
function branchKey(run) {
|
|
16226
|
+
return run.currentBranchPath.join("_");
|
|
16227
|
+
}
|
|
16008
16228
|
function unitToMs(value, unit) {
|
|
16009
16229
|
const m = 6e4;
|
|
16010
16230
|
switch (unit) {
|
|
@@ -16031,6 +16251,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16031
16251
|
}
|
|
16032
16252
|
}
|
|
16033
16253
|
}
|
|
16254
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16034
16255
|
async function processScheduledBroadcasts(ctx) {
|
|
16035
16256
|
const now = /* @__PURE__ */ new Date();
|
|
16036
16257
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -16041,15 +16262,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16041
16262
|
{ returnDocument: "after" }
|
|
16042
16263
|
);
|
|
16043
16264
|
if (!claimed) continue;
|
|
16044
|
-
|
|
16045
|
-
|
|
16046
|
-
|
|
16047
|
-
|
|
16048
|
-
|
|
16049
|
-
|
|
16050
|
-
|
|
16051
|
-
|
|
16265
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
16266
|
+
}
|
|
16267
|
+
}
|
|
16268
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
16269
|
+
if (ctx.config.queue.driver === "noop") {
|
|
16270
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16271
|
+
return;
|
|
16272
|
+
}
|
|
16273
|
+
await ctx.queues.advance.add(
|
|
16274
|
+
"advance",
|
|
16275
|
+
{ broadcastId: String(broadcast._id) },
|
|
16276
|
+
{
|
|
16277
|
+
attempts: 3,
|
|
16278
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
16279
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
16052
16280
|
}
|
|
16281
|
+
);
|
|
16282
|
+
}
|
|
16283
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
16284
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
16285
|
+
if (!broadcast) return;
|
|
16286
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16287
|
+
}
|
|
16288
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
16289
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16290
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16291
|
+
for (const b of stalled) {
|
|
16292
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16293
|
+
{ _id: b._id },
|
|
16294
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16295
|
+
);
|
|
16296
|
+
await startBroadcastDispatch(b, ctx);
|
|
16297
|
+
}
|
|
16298
|
+
}
|
|
16299
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
16300
|
+
try {
|
|
16301
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
16302
|
+
} catch (err) {
|
|
16303
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16304
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16305
|
+
{ _id: broadcast._id },
|
|
16306
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16307
|
+
);
|
|
16053
16308
|
}
|
|
16054
16309
|
}
|
|
16055
16310
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -16070,11 +16325,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16070
16325
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
16071
16326
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
16072
16327
|
for (; ; ) {
|
|
16328
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16329
|
+
{ _id: broadcast._id },
|
|
16330
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16331
|
+
);
|
|
16073
16332
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
16074
16333
|
if (page.contacts.length === 0) break;
|
|
16075
16334
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
16076
16335
|
if (eligible.length > 0) {
|
|
16077
16336
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16337
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16338
|
+
{ _id: broadcast._id },
|
|
16339
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16340
|
+
);
|
|
16078
16341
|
await sleep(2e3);
|
|
16079
16342
|
}
|
|
16080
16343
|
const sendDocs = await Promise.all(
|
|
@@ -16197,7 +16460,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16197
16460
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
16198
16461
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
16199
16462
|
if (respectTimezone && contact.timezone) {
|
|
16200
|
-
|
|
16463
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
16464
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
16201
16465
|
}
|
|
16202
16466
|
const doc = {
|
|
16203
16467
|
_id: sendId,
|
|
@@ -16236,7 +16500,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16236
16500
|
};
|
|
16237
16501
|
return { doc, delayMs };
|
|
16238
16502
|
}
|
|
16239
|
-
function
|
|
16503
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16504
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16240
16505
|
try {
|
|
16241
16506
|
const scheduled = new Date(scheduledMs);
|
|
16242
16507
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -16249,7 +16514,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
16249
16514
|
const utcMs = parse(utc);
|
|
16250
16515
|
const localMs = parse(local);
|
|
16251
16516
|
const offsetMs = utcMs - localMs;
|
|
16252
|
-
return offsetMs;
|
|
16517
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
16253
16518
|
} catch {
|
|
16254
16519
|
return 0;
|
|
16255
16520
|
}
|
|
@@ -16924,6 +17189,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
|
|
|
16924
17189
|
|
|
16925
17190
|
// src/server/runner/tick.ts
|
|
16926
17191
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
17192
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16927
17193
|
async function runTick(ctx) {
|
|
16928
17194
|
try {
|
|
16929
17195
|
await ctx.collections.health.updateOne(
|
|
@@ -16964,12 +17230,18 @@ async function runTick(ctx) {
|
|
|
16964
17230
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
16965
17231
|
console.error("mailery: broadcast dispatch failed", err);
|
|
16966
17232
|
});
|
|
17233
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17234
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17235
|
+
});
|
|
16967
17236
|
await evaluateHealth(ctx).catch((err) => {
|
|
16968
17237
|
console.error("mailery: health evaluation failed", err);
|
|
16969
17238
|
});
|
|
16970
17239
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
16971
17240
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
16972
17241
|
});
|
|
17242
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
17243
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
17244
|
+
});
|
|
16973
17245
|
await Promise.all([
|
|
16974
17246
|
runDnsblChecks(ctx).catch((err) => {
|
|
16975
17247
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -17040,140 +17312,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
17040
17312
|
await processScheduledBroadcasts(ctx);
|
|
17041
17313
|
}
|
|
17042
17314
|
|
|
17043
|
-
// src/server/runner/webhook.ts
|
|
17044
|
-
function dimsFromSend(send) {
|
|
17045
|
-
if (!send) return null;
|
|
17046
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
17047
|
-
}
|
|
17048
|
-
async function applyWebhookEvent(event, ctx) {
|
|
17049
|
-
const send = await ctx.collections.sends.findOne(
|
|
17050
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
17051
|
-
{ sort: { queuedAt: -1 } }
|
|
17052
|
-
);
|
|
17053
|
-
switch (event.type) {
|
|
17054
|
-
case "delivered":
|
|
17055
|
-
if (send) {
|
|
17056
|
-
await ctx.collections.sends.updateOne(
|
|
17057
|
-
{ _id: send._id },
|
|
17058
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
17059
|
-
);
|
|
17060
|
-
}
|
|
17061
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
17062
|
-
break;
|
|
17063
|
-
case "open":
|
|
17064
|
-
if (send) {
|
|
17065
|
-
await ctx.collections.sends.updateOne(
|
|
17066
|
-
{ _id: send._id },
|
|
17067
|
-
{
|
|
17068
|
-
$set: {
|
|
17069
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
17070
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
17071
|
-
},
|
|
17072
|
-
$inc: { openCount: 1 }
|
|
17073
|
-
}
|
|
17074
|
-
);
|
|
17075
|
-
}
|
|
17076
|
-
break;
|
|
17077
|
-
case "click":
|
|
17078
|
-
if (send) {
|
|
17079
|
-
await ctx.collections.sends.updateOne(
|
|
17080
|
-
{ _id: send._id },
|
|
17081
|
-
{
|
|
17082
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
17083
|
-
$inc: { clickCount: 1 },
|
|
17084
|
-
$push: {
|
|
17085
|
-
clickedLinks: {
|
|
17086
|
-
url: event.details.clickedUrl ?? "",
|
|
17087
|
-
linkId: "",
|
|
17088
|
-
clickedAt: event.occurredAt
|
|
17089
|
-
}
|
|
17090
|
-
}
|
|
17091
|
-
}
|
|
17092
|
-
);
|
|
17093
|
-
}
|
|
17094
|
-
break;
|
|
17095
|
-
case "bounce": {
|
|
17096
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
17097
|
-
if (send) {
|
|
17098
|
-
await ctx.collections.sends.updateOne(
|
|
17099
|
-
{ _id: send._id },
|
|
17100
|
-
{
|
|
17101
|
-
$set: {
|
|
17102
|
-
status: "bounced",
|
|
17103
|
-
bounceType,
|
|
17104
|
-
bounceReason: event.details.bounceReason ?? null
|
|
17105
|
-
}
|
|
17106
|
-
}
|
|
17107
|
-
);
|
|
17108
|
-
}
|
|
17109
|
-
if (bounceType === "hard") {
|
|
17110
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
17111
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17112
|
-
{ emailAtSubscribe: event.email },
|
|
17113
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17114
|
-
);
|
|
17115
|
-
}
|
|
17116
|
-
{
|
|
17117
|
-
const dims = dimsFromSend(send);
|
|
17118
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
17119
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
17120
|
-
}
|
|
17121
|
-
break;
|
|
17122
|
-
}
|
|
17123
|
-
case "complaint":
|
|
17124
|
-
case "spam_report":
|
|
17125
|
-
if (send) {
|
|
17126
|
-
await ctx.collections.sends.updateOne(
|
|
17127
|
-
{ _id: send._id },
|
|
17128
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
17129
|
-
);
|
|
17130
|
-
}
|
|
17131
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
17132
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17133
|
-
{ emailAtSubscribe: event.email },
|
|
17134
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17135
|
-
);
|
|
17136
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
17137
|
-
break;
|
|
17138
|
-
case "unsubscribe":
|
|
17139
|
-
if (send) {
|
|
17140
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
17141
|
-
}
|
|
17142
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
17143
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17144
|
-
{ emailAtSubscribe: event.email },
|
|
17145
|
-
{
|
|
17146
|
-
$set: {
|
|
17147
|
-
status: "unsubscribed",
|
|
17148
|
-
unsubscribedAt: event.occurredAt,
|
|
17149
|
-
unsubscribeReason: "user_request",
|
|
17150
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
17151
|
-
}
|
|
17152
|
-
}
|
|
17153
|
-
);
|
|
17154
|
-
break;
|
|
17155
|
-
}
|
|
17156
|
-
}
|
|
17157
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
17158
|
-
const normalized = email.toLowerCase();
|
|
17159
|
-
await ctx.collections.suppressions.updateOne(
|
|
17160
|
-
{ email: normalized, scope },
|
|
17161
|
-
{
|
|
17162
|
-
$setOnInsert: {
|
|
17163
|
-
email: normalized,
|
|
17164
|
-
emailHash: sha256Hex(normalized),
|
|
17165
|
-
scope,
|
|
17166
|
-
reason,
|
|
17167
|
-
source: "provider_webhook",
|
|
17168
|
-
notes: null,
|
|
17169
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
17170
|
-
expiresAt: null
|
|
17171
|
-
}
|
|
17172
|
-
},
|
|
17173
|
-
{ upsert: true }
|
|
17174
|
-
);
|
|
17175
|
-
}
|
|
17176
|
-
|
|
17177
17315
|
// src/server/mailer.ts
|
|
17178
17316
|
var Mailer = class _Mailer {
|
|
17179
17317
|
db;
|
|
@@ -17638,41 +17776,48 @@ var Mailer = class _Mailer {
|
|
|
17638
17776
|
if (existing) return { sendId: String(existing._id) };
|
|
17639
17777
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
17640
17778
|
const sendId = new ObjectId();
|
|
17641
|
-
|
|
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
|
-
|
|
17779
|
+
try {
|
|
17780
|
+
await this.collections.sends.insertOne({
|
|
17781
|
+
_id: sendId,
|
|
17782
|
+
dedupeKey,
|
|
17783
|
+
externalId: parsed.externalId,
|
|
17784
|
+
emailAtSend: contact.email,
|
|
17785
|
+
templateId: template._id,
|
|
17786
|
+
templateSlug: template.slug,
|
|
17787
|
+
flowRunId: null,
|
|
17788
|
+
broadcastId: null,
|
|
17789
|
+
manualSendBy: "sendOneOff",
|
|
17790
|
+
kind: template.kind,
|
|
17791
|
+
provider: providerName,
|
|
17792
|
+
providerMessageId: null,
|
|
17793
|
+
fromName: template.fromName,
|
|
17794
|
+
fromEmail: template.fromEmail,
|
|
17795
|
+
subject: template.subject,
|
|
17796
|
+
bodyHash: "",
|
|
17797
|
+
status: "queued",
|
|
17798
|
+
errorMessage: null,
|
|
17799
|
+
bounceType: null,
|
|
17800
|
+
bounceReason: null,
|
|
17801
|
+
links: [],
|
|
17802
|
+
vars: parsed.vars ?? {},
|
|
17803
|
+
openedAt: null,
|
|
17804
|
+
openCount: 0,
|
|
17805
|
+
firstClickAt: null,
|
|
17806
|
+
clickCount: 0,
|
|
17807
|
+
clickedLinks: [],
|
|
17808
|
+
unsubscribedAt: null,
|
|
17809
|
+
complainedAt: null,
|
|
17810
|
+
queuedAt: /* @__PURE__ */ new Date(),
|
|
17811
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
17812
|
+
sentAt: null,
|
|
17813
|
+
deliveredAt: null
|
|
17814
|
+
});
|
|
17815
|
+
} catch (err) {
|
|
17816
|
+
if (err?.code !== 11e3) throw err;
|
|
17817
|
+
const winner = await this.collections.sends.findOne({ dedupeKey });
|
|
17818
|
+
if (winner) return { sendId: String(winner._id) };
|
|
17819
|
+
throw err;
|
|
17820
|
+
}
|
|
17676
17821
|
await this.queues.send.add(
|
|
17677
17822
|
"send",
|
|
17678
17823
|
{ sendId: String(sendId) },
|
|
@@ -17716,7 +17861,12 @@ var Mailer = class _Mailer {
|
|
|
17716
17861
|
await runTick(this.runnerContext);
|
|
17717
17862
|
},
|
|
17718
17863
|
advance: async (data) => {
|
|
17719
|
-
if (
|
|
17864
|
+
if (data.broadcastId) {
|
|
17865
|
+
if (!ObjectId.isValid(data.broadcastId)) return;
|
|
17866
|
+
await dispatchBroadcastById(new ObjectId(data.broadcastId), this.runnerContext);
|
|
17867
|
+
return;
|
|
17868
|
+
}
|
|
17869
|
+
if (!data.flowRunId || !ObjectId.isValid(data.flowRunId)) return;
|
|
17720
17870
|
await processOneRunStep(new ObjectId(data.flowRunId), this.runnerContext);
|
|
17721
17871
|
},
|
|
17722
17872
|
send: async (data) => {
|
|
@@ -17732,27 +17882,7 @@ var Mailer = class _Mailer {
|
|
|
17732
17882
|
}
|
|
17733
17883
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
17734
17884
|
async processWebhookBacklog() {
|
|
17735
|
-
|
|
17736
|
-
for (const evt of batch) {
|
|
17737
|
-
try {
|
|
17738
|
-
const normalized = evt.raw?.normalized;
|
|
17739
|
-
const details = normalized?.details ?? {};
|
|
17740
|
-
await applyWebhookEvent(
|
|
17741
|
-
{
|
|
17742
|
-
type: evt.normalizedType,
|
|
17743
|
-
providerEventId: evt.providerEventId,
|
|
17744
|
-
providerMessageId: evt.providerMessageId,
|
|
17745
|
-
email: evt.email,
|
|
17746
|
-
occurredAt: evt.occurredAt,
|
|
17747
|
-
details
|
|
17748
|
-
},
|
|
17749
|
-
this.runnerContext
|
|
17750
|
-
);
|
|
17751
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
17752
|
-
} catch (err) {
|
|
17753
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
17754
|
-
}
|
|
17755
|
-
}
|
|
17885
|
+
await processWebhookBacklog(this.runnerContext);
|
|
17756
17886
|
}
|
|
17757
17887
|
async stop() {
|
|
17758
17888
|
await this.queueDriver.close();
|
|
@@ -17839,6 +17969,207 @@ function matches(c, f) {
|
|
|
17839
17969
|
return true;
|
|
17840
17970
|
}
|
|
17841
17971
|
|
|
17972
|
+
// src/testing/recording-provider.ts
|
|
17973
|
+
var RecordingProvider = class {
|
|
17974
|
+
constructor(inner) {
|
|
17975
|
+
this.inner = inner;
|
|
17976
|
+
}
|
|
17977
|
+
inner;
|
|
17978
|
+
/** Every `SendArgs` handed to the provider, in call order. */
|
|
17979
|
+
sent = [];
|
|
17980
|
+
/** Same calls, with outcome attached. */
|
|
17981
|
+
records = [];
|
|
17982
|
+
get name() {
|
|
17983
|
+
return this.inner.name;
|
|
17984
|
+
}
|
|
17985
|
+
get sendRatePerSecond() {
|
|
17986
|
+
return this.inner.sendRatePerSecond;
|
|
17987
|
+
}
|
|
17988
|
+
async send(args) {
|
|
17989
|
+
this.sent.push(args);
|
|
17990
|
+
const record = { args, result: null, error: null, durationMs: 0, at: /* @__PURE__ */ new Date() };
|
|
17991
|
+
this.records.push(record);
|
|
17992
|
+
const startedAt = Date.now();
|
|
17993
|
+
try {
|
|
17994
|
+
const result = await this.inner.send(args);
|
|
17995
|
+
record.result = result;
|
|
17996
|
+
return result;
|
|
17997
|
+
} catch (err) {
|
|
17998
|
+
record.error = err instanceof Error ? err : new Error(String(err));
|
|
17999
|
+
throw err;
|
|
18000
|
+
} finally {
|
|
18001
|
+
record.durationMs = Date.now() - startedAt;
|
|
18002
|
+
}
|
|
18003
|
+
}
|
|
18004
|
+
async verifyWebhook(rawBody, headers) {
|
|
18005
|
+
return this.inner.verifyWebhook(rawBody, headers);
|
|
18006
|
+
}
|
|
18007
|
+
parseWebhookEvents(payload, headers) {
|
|
18008
|
+
return this.inner.parseWebhookEvents(payload, headers);
|
|
18009
|
+
}
|
|
18010
|
+
/** Last recorded send, or undefined. Sugar for the common single-send assertion. */
|
|
18011
|
+
get last() {
|
|
18012
|
+
return this.sent[this.sent.length - 1];
|
|
18013
|
+
}
|
|
18014
|
+
/** All sends addressed to `email` (case-insensitive). */
|
|
18015
|
+
toRecipient(email) {
|
|
18016
|
+
const lower = email.toLowerCase();
|
|
18017
|
+
return this.sent.filter((s) => s.to.toLowerCase() === lower);
|
|
18018
|
+
}
|
|
18019
|
+
reset() {
|
|
18020
|
+
this.sent.length = 0;
|
|
18021
|
+
this.records.length = 0;
|
|
18022
|
+
}
|
|
18023
|
+
};
|
|
18024
|
+
|
|
18025
|
+
// src/testing/builders.ts
|
|
18026
|
+
var BACKDATE_MS = 6e4;
|
|
18027
|
+
async function buildTemplate(spec) {
|
|
18028
|
+
const now = /* @__PURE__ */ new Date();
|
|
18029
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
18030
|
+
let mjml = "";
|
|
18031
|
+
let html;
|
|
18032
|
+
if (spec.html) {
|
|
18033
|
+
html = spec.html;
|
|
18034
|
+
} else {
|
|
18035
|
+
mjml = spec.mjml ?? wrapMjml(spec.text ?? "Hello {{contact.fields.firstName}}");
|
|
18036
|
+
const compiled = await compileTemplate(mjml);
|
|
18037
|
+
html = compiled.html;
|
|
18038
|
+
}
|
|
18039
|
+
const plainText = spec.plainText ?? derivePlaintext(html);
|
|
18040
|
+
return {
|
|
18041
|
+
slug: spec.slug,
|
|
18042
|
+
name: spec.name ?? spec.slug,
|
|
18043
|
+
description: spec.description ?? "",
|
|
18044
|
+
kind: spec.kind ?? "marketing",
|
|
18045
|
+
fromName: spec.fromName ?? "Test",
|
|
18046
|
+
fromEmail: spec.fromEmail ?? "hello@example.com",
|
|
18047
|
+
replyTo: spec.replyTo ?? null,
|
|
18048
|
+
providerOverride: spec.providerOverride ?? null,
|
|
18049
|
+
subject: spec.subject ?? "Test subject",
|
|
18050
|
+
preheader: spec.preheader ?? "",
|
|
18051
|
+
body: { mjml, editorJson: null, html, plainText, compiledAt: createdAt },
|
|
18052
|
+
variablesSchema: spec.variablesSchema ?? {},
|
|
18053
|
+
draft: null,
|
|
18054
|
+
tags: spec.tags ?? [],
|
|
18055
|
+
trackOpens: spec.trackOpens ?? false,
|
|
18056
|
+
trackClicks: spec.trackClicks ?? false,
|
|
18057
|
+
stats: {
|
|
18058
|
+
sent: 0,
|
|
18059
|
+
delivered: 0,
|
|
18060
|
+
opened: 0,
|
|
18061
|
+
clicked: 0,
|
|
18062
|
+
bounced: 0,
|
|
18063
|
+
complained: 0,
|
|
18064
|
+
unsubscribed: 0,
|
|
18065
|
+
lastSentAt: null
|
|
18066
|
+
},
|
|
18067
|
+
publishedAt: spec.published === false ? null : createdAt,
|
|
18068
|
+
publishedBy: spec.published === false ? null : "test",
|
|
18069
|
+
createdAt,
|
|
18070
|
+
updatedAt: createdAt
|
|
18071
|
+
};
|
|
18072
|
+
}
|
|
18073
|
+
function wrapMjml(body) {
|
|
18074
|
+
return `<mjml><mj-body><mj-section><mj-column><mj-text>${body}</mj-text></mj-column></mj-section></mj-body></mjml>`;
|
|
18075
|
+
}
|
|
18076
|
+
function buildFlow(spec) {
|
|
18077
|
+
const now = /* @__PURE__ */ new Date();
|
|
18078
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
18079
|
+
const trigger = spec.trigger ?? {
|
|
18080
|
+
type: "event",
|
|
18081
|
+
eventName: spec.eventName ?? `${spec.slug}:trigger`,
|
|
18082
|
+
once: spec.once ?? true
|
|
18083
|
+
};
|
|
18084
|
+
return {
|
|
18085
|
+
slug: spec.slug,
|
|
18086
|
+
name: spec.name ?? spec.slug,
|
|
18087
|
+
description: spec.description ?? "",
|
|
18088
|
+
trigger,
|
|
18089
|
+
enabled: spec.enabled ?? true,
|
|
18090
|
+
steps: spec.steps,
|
|
18091
|
+
version: spec.version ?? 1,
|
|
18092
|
+
draft: null,
|
|
18093
|
+
goal: spec.goal ?? "activation",
|
|
18094
|
+
audience: spec.audience ?? "test",
|
|
18095
|
+
expectedVolumePerWeek: null,
|
|
18096
|
+
stats: { activeRuns: 0, completedRuns: 0, sendsTotal: 0, sendsLast7Days: 0 },
|
|
18097
|
+
lastTriggerScanAt: null,
|
|
18098
|
+
publishedAt: createdAt,
|
|
18099
|
+
publishedBy: "test",
|
|
18100
|
+
createdAt,
|
|
18101
|
+
updatedAt: createdAt
|
|
18102
|
+
};
|
|
18103
|
+
}
|
|
18104
|
+
var step = {
|
|
18105
|
+
send(templateSlug, opts = {}) {
|
|
18106
|
+
return { type: "send", templateSlug, ...opts };
|
|
18107
|
+
},
|
|
18108
|
+
sendAt(templateSlug, delivery) {
|
|
18109
|
+
return { type: "send", templateSlug, delivery };
|
|
18110
|
+
},
|
|
18111
|
+
wait(value, unit = "minutes") {
|
|
18112
|
+
return { type: "wait", value, unit };
|
|
18113
|
+
},
|
|
18114
|
+
tag(addTags, removeTags) {
|
|
18115
|
+
return { type: "tag", addTags, removeTags };
|
|
18116
|
+
},
|
|
18117
|
+
exit(reason) {
|
|
18118
|
+
return { type: "exit", reason };
|
|
18119
|
+
},
|
|
18120
|
+
fireEvent(eventName, properties) {
|
|
18121
|
+
return { type: "fire_event", eventName, properties };
|
|
18122
|
+
}
|
|
18123
|
+
};
|
|
18124
|
+
|
|
18125
|
+
// src/testing/drive.ts
|
|
18126
|
+
async function drain(ctx, opts = {}) {
|
|
18127
|
+
const maxRounds = opts.maxRounds ?? 100;
|
|
18128
|
+
const shouldDispatch = opts.dispatch !== false;
|
|
18129
|
+
const attempted = /* @__PURE__ */ new Set();
|
|
18130
|
+
const errors = [];
|
|
18131
|
+
let dispatched = 0;
|
|
18132
|
+
let rounds = 0;
|
|
18133
|
+
while (rounds < maxRounds) {
|
|
18134
|
+
rounds++;
|
|
18135
|
+
await processNewlyFiredEventTriggers(ctx);
|
|
18136
|
+
await sweepStrandedFlowRuns(ctx);
|
|
18137
|
+
let didWork = false;
|
|
18138
|
+
if (shouldDispatch) {
|
|
18139
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18140
|
+
for (const row of queued) {
|
|
18141
|
+
const id = String(row._id);
|
|
18142
|
+
if (attempted.has(id)) continue;
|
|
18143
|
+
attempted.add(id);
|
|
18144
|
+
didWork = true;
|
|
18145
|
+
dispatched++;
|
|
18146
|
+
try {
|
|
18147
|
+
await dispatchSend(row._id, ctx);
|
|
18148
|
+
} catch (err) {
|
|
18149
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
18150
|
+
if (opts.throwOnSendError) throw err;
|
|
18151
|
+
}
|
|
18152
|
+
}
|
|
18153
|
+
}
|
|
18154
|
+
const due = await ctx.collections.flowRuns.countDocuments({
|
|
18155
|
+
status: "active",
|
|
18156
|
+
nextActionAt: { $lte: /* @__PURE__ */ new Date() }
|
|
18157
|
+
});
|
|
18158
|
+
if (!didWork && due === 0) {
|
|
18159
|
+
return { rounds, dispatched, errors, settled: true };
|
|
18160
|
+
}
|
|
18161
|
+
}
|
|
18162
|
+
return { rounds, dispatched, errors, settled: false };
|
|
18163
|
+
}
|
|
18164
|
+
async function dispatchQueued(ctx) {
|
|
18165
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18166
|
+
for (const row of queued) {
|
|
18167
|
+
await dispatchSend(row._id, ctx).catch(() => {
|
|
18168
|
+
});
|
|
18169
|
+
}
|
|
18170
|
+
return queued.length;
|
|
18171
|
+
}
|
|
18172
|
+
|
|
17842
18173
|
// src/testing/index.ts
|
|
17843
18174
|
async function createTestMailer(opts = {}) {
|
|
17844
18175
|
const { MongoMemoryServer } = await Promise.resolve().then(() => __toESM(require_mongodb_memory_server()));
|
|
@@ -17848,13 +18179,13 @@ async function createTestMailer(opts = {}) {
|
|
|
17848
18179
|
const db = client.db("mailery-test");
|
|
17849
18180
|
const memoryAdapter = opts.adapter ? null : new MemoryContactAdapter(opts.seedContacts ?? []);
|
|
17850
18181
|
const adapter = opts.adapter ?? memoryAdapter;
|
|
17851
|
-
const provider = opts.provider ??
|
|
18182
|
+
const provider = new RecordingProvider(await resolveProvider(opts.provider ?? "null"));
|
|
17852
18183
|
const mailer = await Mailer.init({
|
|
17853
18184
|
db,
|
|
17854
18185
|
adapter,
|
|
17855
|
-
queue: { driver: "noop" },
|
|
17856
|
-
providers: {
|
|
17857
|
-
defaultProvider:
|
|
18186
|
+
queue: opts.queue ?? { driver: "noop" },
|
|
18187
|
+
providers: { [provider.name]: provider },
|
|
18188
|
+
defaultProvider: provider.name,
|
|
17858
18189
|
publicUrl: "http://localhost:3000",
|
|
17859
18190
|
unsubscribeSecret: "test-unsub-secret-32-bytes-or-more-please",
|
|
17860
18191
|
senderAddress: "1 Test St, Brooklyn NY 11201",
|
|
@@ -17862,17 +18193,72 @@ async function createTestMailer(opts = {}) {
|
|
|
17862
18193
|
workerless: true,
|
|
17863
18194
|
...opts.config ?? {}
|
|
17864
18195
|
});
|
|
17865
|
-
|
|
17866
|
-
|
|
17867
|
-
|
|
17868
|
-
|
|
17869
|
-
|
|
17870
|
-
|
|
17871
|
-
|
|
18196
|
+
if (opts.startWorkers) await mailer.startWorkers();
|
|
18197
|
+
const ctx = mailer.getRunnerContext();
|
|
18198
|
+
const harness = {
|
|
18199
|
+
mailer,
|
|
18200
|
+
db,
|
|
18201
|
+
provider,
|
|
18202
|
+
adapter,
|
|
18203
|
+
memoryAdapter,
|
|
18204
|
+
ctx,
|
|
18205
|
+
async seedContact(contact, seedOpts = {}) {
|
|
18206
|
+
if (!memoryAdapter) {
|
|
18207
|
+
throw new Error("seedContact requires the built-in MemoryContactAdapter \u2014 you passed a custom `adapter`.");
|
|
18208
|
+
}
|
|
18209
|
+
memoryAdapter.upsert(contact);
|
|
18210
|
+
if (seedOpts.subscribe !== false) {
|
|
18211
|
+
await mailer.upsertSubscription({
|
|
18212
|
+
externalId: contact.externalId,
|
|
18213
|
+
source: seedOpts.source ?? "test"
|
|
18214
|
+
});
|
|
18215
|
+
}
|
|
18216
|
+
return contact;
|
|
18217
|
+
},
|
|
18218
|
+
async seedTemplate(spec) {
|
|
18219
|
+
const doc = await buildTemplate(spec);
|
|
18220
|
+
const res = await ctx.collections.templates.insertOne(doc);
|
|
18221
|
+
doc._id = res.insertedId;
|
|
18222
|
+
return doc;
|
|
18223
|
+
},
|
|
18224
|
+
async seedFlow(spec) {
|
|
18225
|
+
const doc = buildFlow(spec);
|
|
18226
|
+
const res = await ctx.collections.flows.insertOne(doc);
|
|
18227
|
+
doc._id = res.insertedId;
|
|
18228
|
+
return doc;
|
|
18229
|
+
},
|
|
18230
|
+
drain(drainOpts) {
|
|
18231
|
+
return drain(ctx, drainOpts);
|
|
18232
|
+
},
|
|
18233
|
+
stop: async () => {
|
|
18234
|
+
await mailer.stop().catch(() => {
|
|
18235
|
+
});
|
|
18236
|
+
await client.close().catch(() => {
|
|
18237
|
+
});
|
|
18238
|
+
await mongo.stop().catch(() => {
|
|
18239
|
+
});
|
|
18240
|
+
}
|
|
17872
18241
|
};
|
|
17873
|
-
return
|
|
18242
|
+
return harness;
|
|
18243
|
+
}
|
|
18244
|
+
async function resolveProvider(spec) {
|
|
18245
|
+
if (typeof spec !== "string") return spec;
|
|
18246
|
+
if (spec === "null") return new NullProvider();
|
|
18247
|
+
const apiKey = process.env.SENDGRID_API_KEY;
|
|
18248
|
+
if (!apiKey) {
|
|
18249
|
+
throw new Error(
|
|
18250
|
+
"createTestMailer({ provider: 'sendgrid' }) requires SENDGRID_API_KEY. Live tests should gate on it before constructing the harness."
|
|
18251
|
+
);
|
|
18252
|
+
}
|
|
18253
|
+
const { SendGridProvider: SendGridProvider2 } = await Promise.resolve().then(() => (init_sendgrid(), sendgrid_exports));
|
|
18254
|
+
return new SendGridProvider2({
|
|
18255
|
+
apiKey,
|
|
18256
|
+
// Sandbox unless explicitly told to deliver: SendGrid still authenticates
|
|
18257
|
+
// and validates the payload, it just never puts anything in an inbox.
|
|
18258
|
+
sandbox: process.env.MAILERY_LIVE_E2E !== "deliver"
|
|
18259
|
+
});
|
|
17874
18260
|
}
|
|
17875
18261
|
|
|
17876
|
-
export { MemoryContactAdapter, NullProvider, createTestMailer };
|
|
18262
|
+
export { MemoryContactAdapter, NullProvider, RecordingProvider, buildFlow, buildTemplate, createTestMailer, dispatchQueued, drain, step, wrapMjml };
|
|
17877
18263
|
//# sourceMappingURL=testing.js.map
|
|
17878
18264
|
//# sourceMappingURL=testing.js.map
|