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.cjs
CHANGED
|
@@ -504,22 +504,22 @@ function __awaiter(thisArg, _arguments, P, generator) {
|
|
|
504
504
|
return new (P || (P = Promise))(function(resolve, reject) {
|
|
505
505
|
function fulfilled(value) {
|
|
506
506
|
try {
|
|
507
|
-
|
|
507
|
+
step2(generator.next(value));
|
|
508
508
|
} catch (e) {
|
|
509
509
|
reject(e);
|
|
510
510
|
}
|
|
511
511
|
}
|
|
512
512
|
function rejected(value) {
|
|
513
513
|
try {
|
|
514
|
-
|
|
514
|
+
step2(generator["throw"](value));
|
|
515
515
|
} catch (e) {
|
|
516
516
|
reject(e);
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
|
-
function
|
|
519
|
+
function step2(result) {
|
|
520
520
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
|
521
521
|
}
|
|
522
|
-
|
|
522
|
+
step2((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
523
523
|
});
|
|
524
524
|
}
|
|
525
525
|
function __generator(thisArg, body) {
|
|
@@ -532,10 +532,10 @@ function __generator(thisArg, body) {
|
|
|
532
532
|
}), g;
|
|
533
533
|
function verb(n) {
|
|
534
534
|
return function(v) {
|
|
535
|
-
return
|
|
535
|
+
return step2([n, v]);
|
|
536
536
|
};
|
|
537
537
|
}
|
|
538
|
-
function
|
|
538
|
+
function step2(op) {
|
|
539
539
|
if (f) throw new TypeError("Generator is already executing.");
|
|
540
540
|
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
541
541
|
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;
|
|
@@ -669,12 +669,12 @@ function __asyncGenerator(thisArg, _arguments, generator) {
|
|
|
669
669
|
}
|
|
670
670
|
function resume(n, v) {
|
|
671
671
|
try {
|
|
672
|
-
|
|
672
|
+
step2(g[n](v));
|
|
673
673
|
} catch (e) {
|
|
674
674
|
settle(q[0][3], e);
|
|
675
675
|
}
|
|
676
676
|
}
|
|
677
|
-
function
|
|
677
|
+
function step2(r) {
|
|
678
678
|
r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r);
|
|
679
679
|
}
|
|
680
680
|
function fulfill(value) {
|
|
@@ -7092,9 +7092,9 @@ var require_pack = __commonJS({
|
|
|
7092
7092
|
this.push(headers.encode(newHeader));
|
|
7093
7093
|
}
|
|
7094
7094
|
_doDrain() {
|
|
7095
|
-
const
|
|
7095
|
+
const drain2 = this._drain;
|
|
7096
7096
|
this._drain = noop;
|
|
7097
|
-
|
|
7097
|
+
drain2();
|
|
7098
7098
|
}
|
|
7099
7099
|
_predestroy() {
|
|
7100
7100
|
const err = getStreamError(this);
|
|
@@ -14447,6 +14447,7 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14447
14447
|
{ key: { dedupeKey: 1 }, unique: true },
|
|
14448
14448
|
{ key: { externalId: 1, occurredAt: -1 } },
|
|
14449
14449
|
{ key: { name: 1, occurredAt: -1 } },
|
|
14450
|
+
{ key: { name: 1, createdAt: 1 } },
|
|
14450
14451
|
{ key: { externalId: 1, name: 1 } }
|
|
14451
14452
|
]),
|
|
14452
14453
|
c.flows.createIndexes([
|
|
@@ -14457,7 +14458,12 @@ async function ensureIndexes(db, prefix = "mailer_") {
|
|
|
14457
14458
|
c.flowRuns.createIndexes([
|
|
14458
14459
|
{ key: { status: 1, nextActionAt: 1 } },
|
|
14459
14460
|
{ key: { externalId: 1, flowId: 1 } },
|
|
14460
|
-
{ key: { flowId: 1, status: 1 } }
|
|
14461
|
+
{ key: { flowId: 1, status: 1 } },
|
|
14462
|
+
{
|
|
14463
|
+
key: { flowId: 1, triggerDedupeKey: 1 },
|
|
14464
|
+
unique: true,
|
|
14465
|
+
partialFilterExpression: { triggerDedupeKey: { $type: "string" } }
|
|
14466
|
+
}
|
|
14461
14467
|
]),
|
|
14462
14468
|
c.templates.createIndexes([
|
|
14463
14469
|
{ key: { slug: 1 }, unique: true },
|
|
@@ -14645,7 +14651,13 @@ var BullDriver = class _BullDriver {
|
|
|
14645
14651
|
constructor(bull, redis) {
|
|
14646
14652
|
this.bull = bull;
|
|
14647
14653
|
this.redis = redis;
|
|
14648
|
-
const opts = {
|
|
14654
|
+
const opts = {
|
|
14655
|
+
connection: redis,
|
|
14656
|
+
defaultJobOptions: {
|
|
14657
|
+
removeOnComplete: { age: 24 * 3600, count: 1e3 },
|
|
14658
|
+
removeOnFail: { age: 7 * 24 * 3600 }
|
|
14659
|
+
}
|
|
14660
|
+
};
|
|
14649
14661
|
this.bullQueues = {
|
|
14650
14662
|
tick: new bull.Queue(QUEUE_NAMES.tick, opts),
|
|
14651
14663
|
advance: new bull.Queue(QUEUE_NAMES.advance, opts),
|
|
@@ -14663,7 +14675,11 @@ var BullDriver = class _BullDriver {
|
|
|
14663
14675
|
await this.bullQueues.tick.upsertJobScheduler(
|
|
14664
14676
|
"mailer-tick-repeat",
|
|
14665
14677
|
{ every: intervalSeconds * 1e3 },
|
|
14666
|
-
{
|
|
14678
|
+
{
|
|
14679
|
+
name: "tick",
|
|
14680
|
+
data: {},
|
|
14681
|
+
opts: { removeOnComplete: { count: 20 }, removeOnFail: { count: 50 } }
|
|
14682
|
+
}
|
|
14667
14683
|
);
|
|
14668
14684
|
}
|
|
14669
14685
|
async startWorkers(opts) {
|
|
@@ -14773,9 +14789,10 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14773
14789
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
14774
14790
|
);
|
|
14775
14791
|
}
|
|
14792
|
+
const collectionName = opts.collectionName ?? "_mailerJobs";
|
|
14776
14793
|
const backend = new backendMod.MongoBackend({
|
|
14777
14794
|
mongo: opts.db,
|
|
14778
|
-
collection:
|
|
14795
|
+
collection: collectionName
|
|
14779
14796
|
});
|
|
14780
14797
|
const agenda = new agendaMod.Agenda({
|
|
14781
14798
|
backend,
|
|
@@ -14784,13 +14801,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14784
14801
|
maxConcurrency: 50,
|
|
14785
14802
|
defaultConcurrency: 5
|
|
14786
14803
|
});
|
|
14787
|
-
return new _AgendaDriver(agenda, agendaMod, opts.db);
|
|
14804
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName);
|
|
14788
14805
|
}
|
|
14789
14806
|
db;
|
|
14790
|
-
|
|
14807
|
+
collName;
|
|
14808
|
+
constructor(agenda, agendaMod, db, collectionName) {
|
|
14791
14809
|
this.agenda = agenda;
|
|
14792
14810
|
this.agendaMod = agendaMod;
|
|
14793
14811
|
this.db = db;
|
|
14812
|
+
this.collName = collectionName;
|
|
14794
14813
|
this.queues = {
|
|
14795
14814
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
14796
14815
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -14823,10 +14842,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14823
14842
|
}
|
|
14824
14843
|
/** Direct access to the Mongo collection Agenda persists jobs into. */
|
|
14825
14844
|
jobsCollection() {
|
|
14826
|
-
return this.db.collection(this.
|
|
14827
|
-
}
|
|
14828
|
-
collectionName() {
|
|
14829
|
-
return "_mailerJobs";
|
|
14845
|
+
return this.db.collection(this.collName);
|
|
14830
14846
|
}
|
|
14831
14847
|
async findPending(name, jobId) {
|
|
14832
14848
|
return this.jobsCollection().findOne({
|
|
@@ -14836,12 +14852,6 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
14836
14852
|
});
|
|
14837
14853
|
}
|
|
14838
14854
|
async scheduleRepeatingTick(intervalSeconds) {
|
|
14839
|
-
if (!this.started) {
|
|
14840
|
-
this.agenda.define(QUEUE_NAMES2.tick, async () => {
|
|
14841
|
-
}, { concurrency: 1 });
|
|
14842
|
-
await this.agenda.start();
|
|
14843
|
-
this.started = true;
|
|
14844
|
-
}
|
|
14845
14855
|
await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
|
|
14846
14856
|
}
|
|
14847
14857
|
async startWorkers(opts) {
|
|
@@ -14944,6 +14954,7 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
14944
14954
|
|
|
14945
14955
|
// src/server/runner/triggers.ts
|
|
14946
14956
|
var BATCH_SIZE = 1e3;
|
|
14957
|
+
var SCAN_OVERLAP_MS = 3e4;
|
|
14947
14958
|
async function processNewlyFiredEventTriggers(ctx) {
|
|
14948
14959
|
const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
|
|
14949
14960
|
for (const flow of flows) {
|
|
@@ -14954,16 +14965,19 @@ async function processFlowTriggers(flow, ctx) {
|
|
|
14954
14965
|
const eventName = flow.trigger.eventName;
|
|
14955
14966
|
if (!eventName) return;
|
|
14956
14967
|
const since = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
14957
|
-
const
|
|
14968
|
+
const scanFrom = new Date(since.getTime() - SCAN_OVERLAP_MS);
|
|
14969
|
+
const events = await ctx.collections.events.find({ name: eventName, createdAt: { $gt: scanFrom } }).sort({ createdAt: 1 }).limit(BATCH_SIZE).toArray();
|
|
14958
14970
|
if (events.length === 0) return;
|
|
14959
14971
|
for (const event of events) {
|
|
14960
14972
|
await tryEnterFlow(flow, event, ctx);
|
|
14961
14973
|
}
|
|
14962
|
-
const
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
14974
|
+
const newestCreatedAt = events[events.length - 1].createdAt;
|
|
14975
|
+
if (newestCreatedAt.getTime() > since.getTime()) {
|
|
14976
|
+
await ctx.collections.flows.updateOne(
|
|
14977
|
+
{ _id: flow._id },
|
|
14978
|
+
{ $set: { lastTriggerScanAt: newestCreatedAt, updatedAt: /* @__PURE__ */ new Date() } }
|
|
14979
|
+
);
|
|
14980
|
+
}
|
|
14967
14981
|
}
|
|
14968
14982
|
async function tryEnterFlow(flow, event, ctx) {
|
|
14969
14983
|
if (flow.trigger.once) {
|
|
@@ -14975,28 +14989,364 @@ async function tryEnterFlow(flow, event, ctx) {
|
|
|
14975
14989
|
}
|
|
14976
14990
|
const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
|
|
14977
14991
|
if (!sub || sub.status !== "subscribed") return;
|
|
14978
|
-
|
|
14979
|
-
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14984
|
-
|
|
14985
|
-
|
|
14986
|
-
|
|
14987
|
-
|
|
14988
|
-
|
|
14989
|
-
|
|
14990
|
-
|
|
14991
|
-
|
|
14992
|
-
|
|
14993
|
-
|
|
14994
|
-
|
|
14995
|
-
|
|
14996
|
-
|
|
14992
|
+
let result;
|
|
14993
|
+
try {
|
|
14994
|
+
result = await ctx.collections.flowRuns.insertOne({
|
|
14995
|
+
externalId: event.externalId,
|
|
14996
|
+
flowId: flow._id,
|
|
14997
|
+
flowSlug: flow.slug,
|
|
14998
|
+
flowVersion: flow.version,
|
|
14999
|
+
emailAtEntry: sub.emailAtSubscribe,
|
|
15000
|
+
triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
|
|
15001
|
+
triggerDedupeKey: event.dedupeKey,
|
|
15002
|
+
enteredAt: /* @__PURE__ */ new Date(),
|
|
15003
|
+
status: "active",
|
|
15004
|
+
currentStepIndex: 0,
|
|
15005
|
+
currentBranchPath: [],
|
|
15006
|
+
nextActionAt: /* @__PURE__ */ new Date(),
|
|
15007
|
+
attemptsForCurrentStep: 0,
|
|
15008
|
+
history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
|
|
15009
|
+
exitedAt: null,
|
|
15010
|
+
exitReason: null,
|
|
15011
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
15012
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15013
|
+
});
|
|
15014
|
+
} catch (err) {
|
|
15015
|
+
if (err?.code !== 11e3) throw err;
|
|
15016
|
+
return;
|
|
15017
|
+
}
|
|
14997
15018
|
await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
|
|
14998
15019
|
}
|
|
14999
15020
|
|
|
15021
|
+
// src/server/templates/sender-domain.ts
|
|
15022
|
+
function extractDomain(email) {
|
|
15023
|
+
if (typeof email !== "string") return null;
|
|
15024
|
+
const at = email.lastIndexOf("@");
|
|
15025
|
+
if (at <= 0 || at === email.length - 1) return null;
|
|
15026
|
+
return email.slice(at + 1).toLowerCase().trim();
|
|
15027
|
+
}
|
|
15028
|
+
|
|
15029
|
+
// src/server/runner/health.ts
|
|
15030
|
+
var ZERO_COUNTERS = {
|
|
15031
|
+
sent: 0,
|
|
15032
|
+
delivered: 0,
|
|
15033
|
+
bounced: 0,
|
|
15034
|
+
hardBounced: 0,
|
|
15035
|
+
softBounced: 0,
|
|
15036
|
+
complained: 0,
|
|
15037
|
+
failedToSend: 0
|
|
15038
|
+
};
|
|
15039
|
+
var ZERO_RATES = {
|
|
15040
|
+
bounceRate: 0,
|
|
15041
|
+
hardBounceRate: 0,
|
|
15042
|
+
complaintRate: 0,
|
|
15043
|
+
failureRate: 0
|
|
15044
|
+
};
|
|
15045
|
+
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15046
|
+
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15047
|
+
const writes = [];
|
|
15048
|
+
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15049
|
+
if (dims) {
|
|
15050
|
+
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15051
|
+
if (domain) {
|
|
15052
|
+
const id = healthBucketId(domain, dims.kind);
|
|
15053
|
+
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15054
|
+
}
|
|
15055
|
+
}
|
|
15056
|
+
await Promise.all(writes);
|
|
15057
|
+
}
|
|
15058
|
+
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15059
|
+
await ctx.collections.health.updateOne(
|
|
15060
|
+
{ _id },
|
|
15061
|
+
{
|
|
15062
|
+
$inc: { [`counters.${counter2}`]: by },
|
|
15063
|
+
$setOnInsert: {
|
|
15064
|
+
_id,
|
|
15065
|
+
senderDomain,
|
|
15066
|
+
kind,
|
|
15067
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15068
|
+
windowDurationMs: windowMs,
|
|
15069
|
+
status: "healthy",
|
|
15070
|
+
trippedAt: null,
|
|
15071
|
+
trippedReason: null,
|
|
15072
|
+
manuallyResumedAt: null,
|
|
15073
|
+
rates: { ...ZERO_RATES }
|
|
15074
|
+
},
|
|
15075
|
+
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15076
|
+
},
|
|
15077
|
+
{ upsert: true }
|
|
15078
|
+
);
|
|
15079
|
+
}
|
|
15080
|
+
async function evaluateHealth(ctx) {
|
|
15081
|
+
const cb = ctx.config.circuitBreaker;
|
|
15082
|
+
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15083
|
+
const docs = await ctx.collections.health.find({}).toArray();
|
|
15084
|
+
if (docs.length === 0) return;
|
|
15085
|
+
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15086
|
+
if (!hasAgg) {
|
|
15087
|
+
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15088
|
+
}
|
|
15089
|
+
for (const doc of docs) {
|
|
15090
|
+
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15091
|
+
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15092
|
+
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15093
|
+
await ctx.collections.health.updateOne(
|
|
15094
|
+
{ _id: doc._id },
|
|
15095
|
+
{
|
|
15096
|
+
$set: {
|
|
15097
|
+
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15098
|
+
windowDurationMs: windowMs,
|
|
15099
|
+
counters: { ...ZERO_COUNTERS },
|
|
15100
|
+
rates: { ...ZERO_RATES },
|
|
15101
|
+
status: "healthy",
|
|
15102
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15103
|
+
}
|
|
15104
|
+
}
|
|
15105
|
+
);
|
|
15106
|
+
continue;
|
|
15107
|
+
}
|
|
15108
|
+
const c = doc.counters;
|
|
15109
|
+
const total = c.sent || 1;
|
|
15110
|
+
const rates = {
|
|
15111
|
+
bounceRate: c.bounced / total,
|
|
15112
|
+
hardBounceRate: c.hardBounced / total,
|
|
15113
|
+
complaintRate: c.complained / total,
|
|
15114
|
+
failureRate: c.failedToSend / total
|
|
15115
|
+
};
|
|
15116
|
+
await ctx.collections.health.updateOne(
|
|
15117
|
+
{ _id: doc._id },
|
|
15118
|
+
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15119
|
+
);
|
|
15120
|
+
if (isAgg) continue;
|
|
15121
|
+
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15122
|
+
if (doc.status === "tripped") continue;
|
|
15123
|
+
let trippedReason = null;
|
|
15124
|
+
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15125
|
+
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15126
|
+
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15127
|
+
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15128
|
+
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15129
|
+
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15130
|
+
}
|
|
15131
|
+
if (trippedReason) {
|
|
15132
|
+
const result = await ctx.collections.health.updateOne(
|
|
15133
|
+
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15134
|
+
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15135
|
+
);
|
|
15136
|
+
if (result.modifiedCount > 0) {
|
|
15137
|
+
if (ctx.audit) {
|
|
15138
|
+
try {
|
|
15139
|
+
await ctx.audit({
|
|
15140
|
+
actor: "system:circuit-breaker",
|
|
15141
|
+
action: "health.trip",
|
|
15142
|
+
resource: {
|
|
15143
|
+
collection: "mailer_health",
|
|
15144
|
+
id: String(doc._id),
|
|
15145
|
+
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15146
|
+
},
|
|
15147
|
+
diffSummary: trippedReason
|
|
15148
|
+
});
|
|
15149
|
+
} catch {
|
|
15150
|
+
}
|
|
15151
|
+
}
|
|
15152
|
+
if (ctx.config.onCircuitBreakerTrip) {
|
|
15153
|
+
try {
|
|
15154
|
+
await ctx.config.onCircuitBreakerTrip({
|
|
15155
|
+
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15156
|
+
rates
|
|
15157
|
+
});
|
|
15158
|
+
} catch {
|
|
15159
|
+
}
|
|
15160
|
+
}
|
|
15161
|
+
}
|
|
15162
|
+
continue;
|
|
15163
|
+
}
|
|
15164
|
+
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15165
|
+
if (doc.status !== "degraded") {
|
|
15166
|
+
await ctx.collections.health.updateOne(
|
|
15167
|
+
{ _id: doc._id },
|
|
15168
|
+
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15169
|
+
);
|
|
15170
|
+
}
|
|
15171
|
+
} else if (doc.status === "degraded") {
|
|
15172
|
+
await ctx.collections.health.updateOne(
|
|
15173
|
+
{ _id: doc._id },
|
|
15174
|
+
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15175
|
+
);
|
|
15176
|
+
}
|
|
15177
|
+
}
|
|
15178
|
+
}
|
|
15179
|
+
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15180
|
+
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15181
|
+
const id = healthBucketId(domain, kind);
|
|
15182
|
+
return ctx.collections.health.findOne({ _id: id });
|
|
15183
|
+
}
|
|
15184
|
+
|
|
15185
|
+
// src/server/runner/webhook.ts
|
|
15186
|
+
function dimsFromSend(send) {
|
|
15187
|
+
if (!send) return null;
|
|
15188
|
+
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
15189
|
+
}
|
|
15190
|
+
async function processWebhookBacklog(ctx, opts = {}) {
|
|
15191
|
+
const filter = { processed: false };
|
|
15192
|
+
if (opts.olderThanMs) {
|
|
15193
|
+
filter.receivedAt = { $lt: new Date(Date.now() - opts.olderThanMs) };
|
|
15194
|
+
}
|
|
15195
|
+
const batch = await ctx.collections.webhookEvents.find(filter).limit(500).toArray();
|
|
15196
|
+
for (const evt of batch) {
|
|
15197
|
+
const claimed = await ctx.collections.webhookEvents.findOneAndUpdate(
|
|
15198
|
+
{ _id: evt._id, processed: false },
|
|
15199
|
+
{ $set: { processed: true } }
|
|
15200
|
+
);
|
|
15201
|
+
if (!claimed) continue;
|
|
15202
|
+
try {
|
|
15203
|
+
const normalized = evt.raw?.normalized;
|
|
15204
|
+
const details = normalized?.details ?? {};
|
|
15205
|
+
await applyWebhookEvent(
|
|
15206
|
+
{
|
|
15207
|
+
type: evt.normalizedType,
|
|
15208
|
+
providerEventId: evt.providerEventId,
|
|
15209
|
+
providerMessageId: evt.providerMessageId,
|
|
15210
|
+
email: evt.email,
|
|
15211
|
+
occurredAt: evt.occurredAt,
|
|
15212
|
+
details
|
|
15213
|
+
},
|
|
15214
|
+
ctx
|
|
15215
|
+
);
|
|
15216
|
+
} catch (err) {
|
|
15217
|
+
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
15218
|
+
}
|
|
15219
|
+
}
|
|
15220
|
+
}
|
|
15221
|
+
async function applyWebhookEvent(event, ctx) {
|
|
15222
|
+
const send = await ctx.collections.sends.findOne(
|
|
15223
|
+
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
15224
|
+
{ sort: { queuedAt: -1 } }
|
|
15225
|
+
);
|
|
15226
|
+
switch (event.type) {
|
|
15227
|
+
case "delivered":
|
|
15228
|
+
if (send) {
|
|
15229
|
+
await ctx.collections.sends.updateOne(
|
|
15230
|
+
{ _id: send._id },
|
|
15231
|
+
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
15232
|
+
);
|
|
15233
|
+
}
|
|
15234
|
+
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
15235
|
+
break;
|
|
15236
|
+
case "open":
|
|
15237
|
+
if (send) {
|
|
15238
|
+
await ctx.collections.sends.updateOne(
|
|
15239
|
+
{ _id: send._id },
|
|
15240
|
+
{
|
|
15241
|
+
$set: {
|
|
15242
|
+
openedAt: send.openedAt ?? event.occurredAt,
|
|
15243
|
+
status: send.status === "sent" ? "delivered" : send.status
|
|
15244
|
+
},
|
|
15245
|
+
$inc: { openCount: 1 }
|
|
15246
|
+
}
|
|
15247
|
+
);
|
|
15248
|
+
}
|
|
15249
|
+
break;
|
|
15250
|
+
case "click":
|
|
15251
|
+
if (send) {
|
|
15252
|
+
await ctx.collections.sends.updateOne(
|
|
15253
|
+
{ _id: send._id },
|
|
15254
|
+
{
|
|
15255
|
+
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
15256
|
+
$inc: { clickCount: 1 },
|
|
15257
|
+
$push: {
|
|
15258
|
+
clickedLinks: {
|
|
15259
|
+
url: event.details.clickedUrl ?? "",
|
|
15260
|
+
linkId: "",
|
|
15261
|
+
clickedAt: event.occurredAt
|
|
15262
|
+
}
|
|
15263
|
+
}
|
|
15264
|
+
}
|
|
15265
|
+
);
|
|
15266
|
+
}
|
|
15267
|
+
break;
|
|
15268
|
+
case "bounce": {
|
|
15269
|
+
const bounceType = event.details.bounceType ?? "hard";
|
|
15270
|
+
if (send) {
|
|
15271
|
+
await ctx.collections.sends.updateOne(
|
|
15272
|
+
{ _id: send._id },
|
|
15273
|
+
{
|
|
15274
|
+
$set: {
|
|
15275
|
+
status: "bounced",
|
|
15276
|
+
bounceType,
|
|
15277
|
+
bounceReason: event.details.bounceReason ?? null
|
|
15278
|
+
}
|
|
15279
|
+
}
|
|
15280
|
+
);
|
|
15281
|
+
}
|
|
15282
|
+
if (bounceType === "hard") {
|
|
15283
|
+
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
15284
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15285
|
+
{ emailAtSubscribe: event.email },
|
|
15286
|
+
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15287
|
+
);
|
|
15288
|
+
}
|
|
15289
|
+
{
|
|
15290
|
+
const dims = dimsFromSend(send);
|
|
15291
|
+
await recordHealthCounter(ctx, "bounced", dims);
|
|
15292
|
+
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
15293
|
+
}
|
|
15294
|
+
break;
|
|
15295
|
+
}
|
|
15296
|
+
case "complaint":
|
|
15297
|
+
case "spam_report":
|
|
15298
|
+
if (send) {
|
|
15299
|
+
await ctx.collections.sends.updateOne(
|
|
15300
|
+
{ _id: send._id },
|
|
15301
|
+
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
15302
|
+
);
|
|
15303
|
+
}
|
|
15304
|
+
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
15305
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15306
|
+
{ emailAtSubscribe: event.email },
|
|
15307
|
+
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15308
|
+
);
|
|
15309
|
+
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
15310
|
+
break;
|
|
15311
|
+
case "unsubscribe":
|
|
15312
|
+
if (send) {
|
|
15313
|
+
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
15314
|
+
}
|
|
15315
|
+
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
15316
|
+
await ctx.collections.subscriptions.updateOne(
|
|
15317
|
+
{ emailAtSubscribe: event.email },
|
|
15318
|
+
{
|
|
15319
|
+
$set: {
|
|
15320
|
+
status: "unsubscribed",
|
|
15321
|
+
unsubscribedAt: event.occurredAt,
|
|
15322
|
+
unsubscribeReason: "user_request",
|
|
15323
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
15324
|
+
}
|
|
15325
|
+
}
|
|
15326
|
+
);
|
|
15327
|
+
break;
|
|
15328
|
+
}
|
|
15329
|
+
}
|
|
15330
|
+
async function suppressOnce(ctx, email, reason, scope) {
|
|
15331
|
+
const normalized = email.toLowerCase();
|
|
15332
|
+
await ctx.collections.suppressions.updateOne(
|
|
15333
|
+
{ email: normalized, scope },
|
|
15334
|
+
{
|
|
15335
|
+
$setOnInsert: {
|
|
15336
|
+
email: normalized,
|
|
15337
|
+
emailHash: sha256Hex(normalized),
|
|
15338
|
+
scope,
|
|
15339
|
+
reason,
|
|
15340
|
+
source: "provider_webhook",
|
|
15341
|
+
notes: null,
|
|
15342
|
+
addedAt: /* @__PURE__ */ new Date(),
|
|
15343
|
+
expiresAt: null
|
|
15344
|
+
}
|
|
15345
|
+
},
|
|
15346
|
+
{ upsert: true }
|
|
15347
|
+
);
|
|
15348
|
+
}
|
|
15349
|
+
|
|
15000
15350
|
// src/server/runner/predicate.ts
|
|
15001
15351
|
var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
15002
15352
|
async function evaluatePredicate(predicate, ctx) {
|
|
@@ -15220,7 +15570,8 @@ function applyTracking(html, opts) {
|
|
|
15220
15570
|
const seen = /* @__PURE__ */ new Map();
|
|
15221
15571
|
let out = html;
|
|
15222
15572
|
if (opts.trackClicks) {
|
|
15223
|
-
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q,
|
|
15573
|
+
out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, rawUrl, post) => {
|
|
15574
|
+
const url = decodeHtmlEntities(rawUrl);
|
|
15224
15575
|
if (shouldSkipClickRewrite(url, preserve, full)) return full;
|
|
15225
15576
|
let linkId = seen.get(url);
|
|
15226
15577
|
if (!linkId) {
|
|
@@ -15252,6 +15603,29 @@ function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
|
15252
15603
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
15253
15604
|
return false;
|
|
15254
15605
|
}
|
|
15606
|
+
var NAMED_ENTITIES = {
|
|
15607
|
+
amp: "&",
|
|
15608
|
+
lt: "<",
|
|
15609
|
+
gt: ">",
|
|
15610
|
+
quot: '"',
|
|
15611
|
+
apos: "'",
|
|
15612
|
+
nbsp: "\xA0"
|
|
15613
|
+
};
|
|
15614
|
+
function decodeHtmlEntities(input) {
|
|
15615
|
+
return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
15616
|
+
if (body[0] === "#") {
|
|
15617
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
15618
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return match;
|
|
15619
|
+
try {
|
|
15620
|
+
return String.fromCodePoint(code);
|
|
15621
|
+
} catch {
|
|
15622
|
+
return match;
|
|
15623
|
+
}
|
|
15624
|
+
}
|
|
15625
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
15626
|
+
return named ?? match;
|
|
15627
|
+
});
|
|
15628
|
+
}
|
|
15255
15629
|
function shortHash(input) {
|
|
15256
15630
|
return crypto2__default.default.createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
15257
15631
|
}
|
|
@@ -15320,175 +15694,11 @@ async function isSuppressed(collections, email, kind) {
|
|
|
15320
15694
|
return { suppressed: false };
|
|
15321
15695
|
}
|
|
15322
15696
|
|
|
15323
|
-
// src/server/templates/sender-domain.ts
|
|
15324
|
-
function extractDomain(email) {
|
|
15325
|
-
if (typeof email !== "string") return null;
|
|
15326
|
-
const at = email.lastIndexOf("@");
|
|
15327
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
15328
|
-
return email.slice(at + 1).toLowerCase().trim();
|
|
15329
|
-
}
|
|
15330
|
-
|
|
15331
|
-
// src/server/runner/health.ts
|
|
15332
|
-
var ZERO_COUNTERS = {
|
|
15333
|
-
sent: 0,
|
|
15334
|
-
delivered: 0,
|
|
15335
|
-
bounced: 0,
|
|
15336
|
-
hardBounced: 0,
|
|
15337
|
-
softBounced: 0,
|
|
15338
|
-
complained: 0,
|
|
15339
|
-
failedToSend: 0
|
|
15340
|
-
};
|
|
15341
|
-
var ZERO_RATES = {
|
|
15342
|
-
bounceRate: 0,
|
|
15343
|
-
hardBounceRate: 0,
|
|
15344
|
-
complaintRate: 0,
|
|
15345
|
-
failureRate: 0
|
|
15346
|
-
};
|
|
15347
|
-
async function recordHealthCounter(ctx, counter2, dims, by = 1) {
|
|
15348
|
-
const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
|
|
15349
|
-
const writes = [];
|
|
15350
|
-
writes.push(upsertCounter(ctx, HEALTH_AGG_ID, null, null, counter2, by, windowMs));
|
|
15351
|
-
if (dims) {
|
|
15352
|
-
const domain = dims.fromEmail ? extractDomain(dims.fromEmail) : null;
|
|
15353
|
-
if (domain) {
|
|
15354
|
-
const id = healthBucketId(domain, dims.kind);
|
|
15355
|
-
writes.push(upsertCounter(ctx, id, domain, dims.kind, counter2, by, windowMs));
|
|
15356
|
-
}
|
|
15357
|
-
}
|
|
15358
|
-
await Promise.all(writes);
|
|
15359
|
-
}
|
|
15360
|
-
async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowMs) {
|
|
15361
|
-
await ctx.collections.health.updateOne(
|
|
15362
|
-
{ _id },
|
|
15363
|
-
{
|
|
15364
|
-
$inc: { [`counters.${counter2}`]: by },
|
|
15365
|
-
$setOnInsert: {
|
|
15366
|
-
_id,
|
|
15367
|
-
senderDomain,
|
|
15368
|
-
kind,
|
|
15369
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15370
|
-
windowDurationMs: windowMs,
|
|
15371
|
-
status: "healthy",
|
|
15372
|
-
trippedAt: null,
|
|
15373
|
-
trippedReason: null,
|
|
15374
|
-
manuallyResumedAt: null,
|
|
15375
|
-
rates: { ...ZERO_RATES }
|
|
15376
|
-
},
|
|
15377
|
-
$set: { updatedAt: /* @__PURE__ */ new Date() }
|
|
15378
|
-
},
|
|
15379
|
-
{ upsert: true }
|
|
15380
|
-
);
|
|
15381
|
-
}
|
|
15382
|
-
async function evaluateHealth(ctx) {
|
|
15383
|
-
const cb = ctx.config.circuitBreaker;
|
|
15384
|
-
const windowMs = cb.windowMinutes * 60 * 1e3;
|
|
15385
|
-
const docs = await ctx.collections.health.find({}).toArray();
|
|
15386
|
-
if (docs.length === 0) return;
|
|
15387
|
-
const hasAgg = docs.some((d) => d._id === HEALTH_AGG_ID);
|
|
15388
|
-
if (!hasAgg) {
|
|
15389
|
-
await upsertCounter(ctx, HEALTH_AGG_ID, null, null, "sent", 0, windowMs);
|
|
15390
|
-
}
|
|
15391
|
-
for (const doc of docs) {
|
|
15392
|
-
const isAgg = doc._id === HEALTH_AGG_ID;
|
|
15393
|
-
const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
|
|
15394
|
-
if (windowAge > windowMs && doc.status !== "tripped") {
|
|
15395
|
-
await ctx.collections.health.updateOne(
|
|
15396
|
-
{ _id: doc._id },
|
|
15397
|
-
{
|
|
15398
|
-
$set: {
|
|
15399
|
-
windowStartedAt: /* @__PURE__ */ new Date(),
|
|
15400
|
-
windowDurationMs: windowMs,
|
|
15401
|
-
counters: { ...ZERO_COUNTERS },
|
|
15402
|
-
rates: { ...ZERO_RATES },
|
|
15403
|
-
status: "healthy",
|
|
15404
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
15405
|
-
}
|
|
15406
|
-
}
|
|
15407
|
-
);
|
|
15408
|
-
continue;
|
|
15409
|
-
}
|
|
15410
|
-
const c = doc.counters;
|
|
15411
|
-
const total = c.sent || 1;
|
|
15412
|
-
const rates = {
|
|
15413
|
-
bounceRate: c.bounced / total,
|
|
15414
|
-
hardBounceRate: c.hardBounced / total,
|
|
15415
|
-
complaintRate: c.complained / total,
|
|
15416
|
-
failureRate: c.failedToSend / total
|
|
15417
|
-
};
|
|
15418
|
-
await ctx.collections.health.updateOne(
|
|
15419
|
-
{ _id: doc._id },
|
|
15420
|
-
{ $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15421
|
-
);
|
|
15422
|
-
if (isAgg) continue;
|
|
15423
|
-
if (c.sent < cb.minSendsBeforeEval) continue;
|
|
15424
|
-
if (doc.status === "tripped") continue;
|
|
15425
|
-
let trippedReason = null;
|
|
15426
|
-
if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
|
|
15427
|
-
trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
|
|
15428
|
-
} else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
|
|
15429
|
-
trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
|
|
15430
|
-
} else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
|
|
15431
|
-
trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
|
|
15432
|
-
}
|
|
15433
|
-
if (trippedReason) {
|
|
15434
|
-
const result = await ctx.collections.health.updateOne(
|
|
15435
|
-
{ _id: doc._id, status: { $in: ["healthy", "degraded"] } },
|
|
15436
|
-
{ $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
|
|
15437
|
-
);
|
|
15438
|
-
if (result.modifiedCount > 0) {
|
|
15439
|
-
if (ctx.audit) {
|
|
15440
|
-
try {
|
|
15441
|
-
await ctx.audit({
|
|
15442
|
-
actor: "system:circuit-breaker",
|
|
15443
|
-
action: "health.trip",
|
|
15444
|
-
resource: {
|
|
15445
|
-
collection: "mailer_health",
|
|
15446
|
-
id: String(doc._id),
|
|
15447
|
-
slug: `${doc.senderDomain ?? "_unknown"}|${doc.kind}`
|
|
15448
|
-
},
|
|
15449
|
-
diffSummary: trippedReason
|
|
15450
|
-
});
|
|
15451
|
-
} catch {
|
|
15452
|
-
}
|
|
15453
|
-
}
|
|
15454
|
-
if (ctx.config.onCircuitBreakerTrip) {
|
|
15455
|
-
try {
|
|
15456
|
-
await ctx.config.onCircuitBreakerTrip({
|
|
15457
|
-
reason: `[${doc.senderDomain ?? "_unknown"} / ${doc.kind}] ${trippedReason}`,
|
|
15458
|
-
rates
|
|
15459
|
-
});
|
|
15460
|
-
} catch {
|
|
15461
|
-
}
|
|
15462
|
-
}
|
|
15463
|
-
}
|
|
15464
|
-
continue;
|
|
15465
|
-
}
|
|
15466
|
-
if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
|
|
15467
|
-
if (doc.status !== "degraded") {
|
|
15468
|
-
await ctx.collections.health.updateOne(
|
|
15469
|
-
{ _id: doc._id },
|
|
15470
|
-
{ $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15471
|
-
);
|
|
15472
|
-
}
|
|
15473
|
-
} else if (doc.status === "degraded") {
|
|
15474
|
-
await ctx.collections.health.updateOne(
|
|
15475
|
-
{ _id: doc._id },
|
|
15476
|
-
{ $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15477
|
-
);
|
|
15478
|
-
}
|
|
15479
|
-
}
|
|
15480
|
-
}
|
|
15481
|
-
async function getBucketStatus(ctx, fromEmail, kind) {
|
|
15482
|
-
const domain = fromEmail ? extractDomain(fromEmail) : null;
|
|
15483
|
-
const id = healthBucketId(domain, kind);
|
|
15484
|
-
return ctx.collections.health.findOne({ _id: id });
|
|
15485
|
-
}
|
|
15486
|
-
|
|
15487
15697
|
// src/server/runner/send.ts
|
|
15488
|
-
async function handleSend(run,
|
|
15489
|
-
const template = await ctx.collections.templates.findOne({ slug:
|
|
15698
|
+
async function handleSend(run, step2, contact, flow, ctx) {
|
|
15699
|
+
const template = await ctx.collections.templates.findOne({ slug: step2.templateSlug });
|
|
15490
15700
|
if (!template) {
|
|
15491
|
-
await failFlowRun(run, `template not found: ${
|
|
15701
|
+
await failFlowRun(run, `template not found: ${step2.templateSlug}`, ctx);
|
|
15492
15702
|
return;
|
|
15493
15703
|
}
|
|
15494
15704
|
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}`;
|
|
@@ -15500,8 +15710,8 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15500
15710
|
});
|
|
15501
15711
|
return;
|
|
15502
15712
|
}
|
|
15503
|
-
const providerName = pickProviderName(
|
|
15504
|
-
const renderCtx = buildRenderContext(contact, run,
|
|
15713
|
+
const providerName = pickProviderName(step2.providerOverride, template, ctx);
|
|
15714
|
+
const renderCtx = buildRenderContext(contact, run, step2.vars ?? {}, ctx);
|
|
15505
15715
|
let rendered;
|
|
15506
15716
|
try {
|
|
15507
15717
|
rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
|
|
@@ -15538,7 +15748,7 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15538
15748
|
bodyHash: sha256(rendered.html),
|
|
15539
15749
|
status: "queued",
|
|
15540
15750
|
renderedFrom: rendered.fromName ? { name: rendered.fromName, email: rendered.fromEmail } : void 0,
|
|
15541
|
-
vars:
|
|
15751
|
+
vars: step2.vars ?? {}
|
|
15542
15752
|
})
|
|
15543
15753
|
);
|
|
15544
15754
|
await ctx.queues.send.add(
|
|
@@ -15549,9 +15759,11 @@ async function handleSend(run, step, contact, flow, ctx) {
|
|
|
15549
15759
|
await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
|
|
15550
15760
|
}
|
|
15551
15761
|
async function dispatchSend(sendId, ctx) {
|
|
15552
|
-
const send = await ctx.collections.sends.
|
|
15762
|
+
const send = await ctx.collections.sends.findOneAndUpdate(
|
|
15763
|
+
{ _id: sendId, status: { $in: ["queued", "failed"] } },
|
|
15764
|
+
{ $set: { status: "sending", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15765
|
+
);
|
|
15553
15766
|
if (!send) return;
|
|
15554
|
-
if (send.status !== "queued" && send.status !== "failed") return;
|
|
15555
15767
|
const template = await ctx.collections.templates.findOne({ _id: send.templateId });
|
|
15556
15768
|
if (!template) {
|
|
15557
15769
|
await markFailed(send._id, "template_missing", ctx);
|
|
@@ -15568,6 +15780,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15568
15780
|
if (send.kind === "marketing") {
|
|
15569
15781
|
const bucket = await getBucketStatus(ctx, send.fromEmail, send.kind);
|
|
15570
15782
|
if (bucket?.status === "tripped") {
|
|
15783
|
+
await ctx.collections.sends.updateOne(
|
|
15784
|
+
{ _id: send._id },
|
|
15785
|
+
{ $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
|
|
15786
|
+
);
|
|
15571
15787
|
await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
|
|
15572
15788
|
return;
|
|
15573
15789
|
}
|
|
@@ -15627,6 +15843,10 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15627
15843
|
await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
|
|
15628
15844
|
return;
|
|
15629
15845
|
}
|
|
15846
|
+
const headers = send.kind === "marketing" ? {
|
|
15847
|
+
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15848
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15849
|
+
} : {};
|
|
15630
15850
|
try {
|
|
15631
15851
|
const result = await provider.send({
|
|
15632
15852
|
to: send.emailAtSend,
|
|
@@ -15636,10 +15856,7 @@ async function dispatchSend(sendId, ctx) {
|
|
|
15636
15856
|
subject: rendered.subject,
|
|
15637
15857
|
html: tracking.html,
|
|
15638
15858
|
text: rendered.plainText,
|
|
15639
|
-
headers
|
|
15640
|
-
"List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
|
|
15641
|
-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
15642
|
-
},
|
|
15859
|
+
headers,
|
|
15643
15860
|
messageMeta: { sendId: String(send._id) }
|
|
15644
15861
|
});
|
|
15645
15862
|
await ctx.collections.sends.updateOne(
|
|
@@ -15751,8 +15968,8 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15751
15968
|
return;
|
|
15752
15969
|
}
|
|
15753
15970
|
const steps = await loadStepsForRun(run, flow, ctx);
|
|
15754
|
-
const
|
|
15755
|
-
if (!
|
|
15971
|
+
const step2 = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
|
|
15972
|
+
if (!step2) {
|
|
15756
15973
|
await completeFlowRun(run, "completed", ctx);
|
|
15757
15974
|
return;
|
|
15758
15975
|
}
|
|
@@ -15766,34 +15983,34 @@ async function processOneRunStep(runId, ctx) {
|
|
|
15766
15983
|
await exitFlowRun(run, sub.status, ctx);
|
|
15767
15984
|
return;
|
|
15768
15985
|
}
|
|
15769
|
-
switch (
|
|
15986
|
+
switch (step2.type) {
|
|
15770
15987
|
case "wait":
|
|
15771
|
-
return handleWait(run,
|
|
15988
|
+
return handleWait(run, step2, ctx);
|
|
15772
15989
|
case "condition":
|
|
15773
|
-
return handleCondition(run,
|
|
15990
|
+
return handleCondition(run, step2, contact, ctx);
|
|
15774
15991
|
case "branch":
|
|
15775
|
-
return handleBranch(run,
|
|
15992
|
+
return handleBranch(run, step2, contact, ctx);
|
|
15776
15993
|
case "send": {
|
|
15777
|
-
if (
|
|
15778
|
-
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(),
|
|
15994
|
+
if (step2.delivery) {
|
|
15995
|
+
const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step2.delivery, contact.timezone);
|
|
15779
15996
|
if (deliverAt.getTime() > Date.now() + 3e4) {
|
|
15780
15997
|
return deferSendForWindow(run, deliverAt, ctx);
|
|
15781
15998
|
}
|
|
15782
15999
|
}
|
|
15783
|
-
return handleSend(run,
|
|
16000
|
+
return handleSend(run, step2, contact, flow, ctx);
|
|
15784
16001
|
}
|
|
15785
16002
|
case "tag":
|
|
15786
|
-
return handleTag(run,
|
|
16003
|
+
return handleTag(run, step2, ctx);
|
|
15787
16004
|
case "fire_event":
|
|
15788
|
-
return handleFireEvent(run,
|
|
16005
|
+
return handleFireEvent(run, step2, ctx);
|
|
15789
16006
|
case "webhook":
|
|
15790
|
-
return handleWebhookStep(run,
|
|
16007
|
+
return handleWebhookStep(run, step2, ctx);
|
|
15791
16008
|
case "exit":
|
|
15792
|
-
return exitFlowRun(run,
|
|
16009
|
+
return exitFlowRun(run, step2.reason ?? "exit_step", ctx);
|
|
15793
16010
|
}
|
|
15794
16011
|
}
|
|
15795
|
-
async function handleWait(run,
|
|
15796
|
-
const ms = unitToMs(
|
|
16012
|
+
async function handleWait(run, step2, ctx) {
|
|
16013
|
+
const ms = unitToMs(step2.value, step2.unit);
|
|
15797
16014
|
const nextAt = new Date(Date.now() + ms);
|
|
15798
16015
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15799
16016
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15815,25 +16032,25 @@ async function handleWait(run, step, ctx) {
|
|
|
15815
16032
|
await ctx.queues.advance.add(
|
|
15816
16033
|
"advance",
|
|
15817
16034
|
{ flowRunId: String(run._id) },
|
|
15818
|
-
{ delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
|
|
16035
|
+
{ delay: ms, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex + 1}` }
|
|
15819
16036
|
);
|
|
15820
16037
|
}
|
|
15821
|
-
async function handleCondition(run,
|
|
16038
|
+
async function handleCondition(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
|
if (result) {
|
|
15825
16042
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
15826
16043
|
return;
|
|
15827
16044
|
}
|
|
15828
|
-
if (
|
|
16045
|
+
if (step2.ifFalse === "continue") {
|
|
15829
16046
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: false, skipped: 1 } }, { stepInc: 2 });
|
|
15830
16047
|
return;
|
|
15831
16048
|
}
|
|
15832
16049
|
await exitFlowRun(run, "condition_false", ctx);
|
|
15833
16050
|
}
|
|
15834
|
-
async function handleBranch(run,
|
|
16051
|
+
async function handleBranch(run, step2, contact, ctx) {
|
|
15835
16052
|
if (!contact) return;
|
|
15836
|
-
const result = await evaluatePredicate(
|
|
16053
|
+
const result = await evaluatePredicate(step2.test, { contact, run, collections: ctx.collections });
|
|
15837
16054
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
15838
16055
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
15839
16056
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -15859,9 +16076,9 @@ async function handleBranch(run, step, contact, ctx) {
|
|
|
15859
16076
|
if (!updated) return;
|
|
15860
16077
|
await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
|
|
15861
16078
|
}
|
|
15862
|
-
async function handleTag(run,
|
|
15863
|
-
const adds =
|
|
15864
|
-
const removes =
|
|
16079
|
+
async function handleTag(run, step2, ctx) {
|
|
16080
|
+
const adds = step2.addTags ?? [];
|
|
16081
|
+
const removes = step2.removeTags ?? [];
|
|
15865
16082
|
if (ctx.adapter.addTags && adds.length > 0) {
|
|
15866
16083
|
await ctx.adapter.addTags(run.externalId, adds);
|
|
15867
16084
|
} else if (adds.length > 0) {
|
|
@@ -15882,13 +16099,13 @@ async function handleTag(run, step, ctx) {
|
|
|
15882
16099
|
}
|
|
15883
16100
|
await advanceStep(run, ctx, { action: "tagged", details: { addTags: adds, removeTags: removes } });
|
|
15884
16101
|
}
|
|
15885
|
-
async function handleFireEvent(run,
|
|
15886
|
-
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${
|
|
16102
|
+
async function handleFireEvent(run, step2, ctx) {
|
|
16103
|
+
const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step2.eventName}`;
|
|
15887
16104
|
try {
|
|
15888
16105
|
await ctx.collections.events.insertOne({
|
|
15889
16106
|
externalId: run.externalId,
|
|
15890
|
-
name:
|
|
15891
|
-
properties:
|
|
16107
|
+
name: step2.eventName,
|
|
16108
|
+
properties: step2.properties ?? {},
|
|
15892
16109
|
dedupeKey,
|
|
15893
16110
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
15894
16111
|
createdAt: /* @__PURE__ */ new Date()
|
|
@@ -15896,27 +16113,27 @@ async function handleFireEvent(run, step, ctx) {
|
|
|
15896
16113
|
} catch (err) {
|
|
15897
16114
|
if (err?.code !== 11e3) throw err;
|
|
15898
16115
|
}
|
|
15899
|
-
await advanceStep(run, ctx, { action: "event_fired", details: { name:
|
|
16116
|
+
await advanceStep(run, ctx, { action: "event_fired", details: { name: step2.eventName } });
|
|
15900
16117
|
}
|
|
15901
|
-
async function handleWebhookStep(run,
|
|
16118
|
+
async function handleWebhookStep(run, step2, ctx) {
|
|
15902
16119
|
try {
|
|
15903
|
-
const res = await fetch(
|
|
15904
|
-
method:
|
|
16120
|
+
const res = await fetch(step2.url, {
|
|
16121
|
+
method: step2.method ?? "POST",
|
|
15905
16122
|
headers: { "Content-Type": "application/json" },
|
|
15906
|
-
body: JSON.stringify(
|
|
16123
|
+
body: JSON.stringify(step2.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
|
|
15907
16124
|
signal: AbortSignal.timeout(1e4)
|
|
15908
16125
|
});
|
|
15909
16126
|
if (!res.ok) throw new Error(`webhook step returned ${res.status}`);
|
|
15910
|
-
await advanceStep(run, ctx, { action: "webhook_called", details: { url:
|
|
16127
|
+
await advanceStep(run, ctx, { action: "webhook_called", details: { url: step2.url, status: res.status } });
|
|
15911
16128
|
} catch (err) {
|
|
15912
16129
|
const attempts = (run.attemptsForCurrentStep ?? 0) + 1;
|
|
15913
16130
|
if (attempts >= ctx.config.webhookRetryAttempts) {
|
|
15914
|
-
if (
|
|
16131
|
+
if (step2.failureMode === "fail_run") {
|
|
15915
16132
|
await failFlowRun(run, `webhook failed: ${err?.message}`, ctx);
|
|
15916
16133
|
} else {
|
|
15917
16134
|
await advanceStep(run, ctx, {
|
|
15918
16135
|
action: "webhook_called",
|
|
15919
|
-
details: { url:
|
|
16136
|
+
details: { url: step2.url, error: err?.message, exhausted: true }
|
|
15920
16137
|
});
|
|
15921
16138
|
}
|
|
15922
16139
|
} else {
|
|
@@ -15927,7 +16144,7 @@ async function handleWebhookStep(run, step, ctx) {
|
|
|
15927
16144
|
await ctx.queues.advance.add(
|
|
15928
16145
|
"advance",
|
|
15929
16146
|
{ flowRunId: String(run._id) },
|
|
15930
|
-
{ delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
|
|
16147
|
+
{ delay: 6e4, jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:retry-${attempts}` }
|
|
15931
16148
|
);
|
|
15932
16149
|
}
|
|
15933
16150
|
}
|
|
@@ -15956,7 +16173,7 @@ async function deferSendForWindow(run, deliverAt, ctx) {
|
|
|
15956
16173
|
{ flowRunId: String(run._id) },
|
|
15957
16174
|
{
|
|
15958
16175
|
delay: Math.max(0, deliverAt.getTime() - Date.now()),
|
|
15959
|
-
jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
16176
|
+
jobId: `advance:${run._id}:${branchKey(run)}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
|
|
15960
16177
|
}
|
|
15961
16178
|
);
|
|
15962
16179
|
}
|
|
@@ -16011,13 +16228,16 @@ function locateStep(steps, currentStepIndex, branchPath) {
|
|
|
16011
16228
|
let arr = steps;
|
|
16012
16229
|
for (let i = 0; i < branchPath.length; i += 3) {
|
|
16013
16230
|
const parentIndex = branchPath[i];
|
|
16014
|
-
const
|
|
16231
|
+
const branchKey2 = branchPath[i + 1];
|
|
16015
16232
|
const parent = arr[parentIndex];
|
|
16016
16233
|
if (!parent || parent.type !== "branch") return null;
|
|
16017
|
-
arr =
|
|
16234
|
+
arr = branchKey2 === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
|
|
16018
16235
|
}
|
|
16019
16236
|
return arr[currentStepIndex] ?? null;
|
|
16020
16237
|
}
|
|
16238
|
+
function branchKey(run) {
|
|
16239
|
+
return run.currentBranchPath.join("_");
|
|
16240
|
+
}
|
|
16021
16241
|
function unitToMs(value, unit) {
|
|
16022
16242
|
const m = 6e4;
|
|
16023
16243
|
switch (unit) {
|
|
@@ -16044,6 +16264,7 @@ async function sweepStrandedFlowRuns(ctx) {
|
|
|
16044
16264
|
}
|
|
16045
16265
|
}
|
|
16046
16266
|
}
|
|
16267
|
+
var STALLED_BROADCAST_THRESHOLD_MS = 10 * 60 * 1e3;
|
|
16047
16268
|
async function processScheduledBroadcasts(ctx) {
|
|
16048
16269
|
const now = /* @__PURE__ */ new Date();
|
|
16049
16270
|
const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
|
|
@@ -16054,15 +16275,49 @@ async function processScheduledBroadcasts(ctx) {
|
|
|
16054
16275
|
{ returnDocument: "after" }
|
|
16055
16276
|
);
|
|
16056
16277
|
if (!claimed) continue;
|
|
16057
|
-
|
|
16058
|
-
|
|
16059
|
-
|
|
16060
|
-
|
|
16061
|
-
|
|
16062
|
-
|
|
16063
|
-
|
|
16064
|
-
|
|
16278
|
+
await startBroadcastDispatch(claimed, ctx);
|
|
16279
|
+
}
|
|
16280
|
+
}
|
|
16281
|
+
async function startBroadcastDispatch(broadcast, ctx) {
|
|
16282
|
+
if (ctx.config.queue.driver === "noop") {
|
|
16283
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16284
|
+
return;
|
|
16285
|
+
}
|
|
16286
|
+
await ctx.queues.advance.add(
|
|
16287
|
+
"advance",
|
|
16288
|
+
{ broadcastId: String(broadcast._id) },
|
|
16289
|
+
{
|
|
16290
|
+
attempts: 3,
|
|
16291
|
+
backoff: { type: "exponential", delay: 6e4 },
|
|
16292
|
+
jobId: `broadcast-dispatch:${broadcast._id}`
|
|
16065
16293
|
}
|
|
16294
|
+
);
|
|
16295
|
+
}
|
|
16296
|
+
async function dispatchBroadcastById(broadcastId, ctx) {
|
|
16297
|
+
const broadcast = await ctx.collections.broadcasts.findOne({ _id: broadcastId, status: "sending" });
|
|
16298
|
+
if (!broadcast) return;
|
|
16299
|
+
await runBroadcastDispatch(broadcast, ctx);
|
|
16300
|
+
}
|
|
16301
|
+
async function resumeStalledBroadcasts(ctx) {
|
|
16302
|
+
const cutoff = new Date(Date.now() - STALLED_BROADCAST_THRESHOLD_MS);
|
|
16303
|
+
const stalled = await ctx.collections.broadcasts.find({ status: "sending", updatedAt: { $lt: cutoff } }).toArray();
|
|
16304
|
+
for (const b of stalled) {
|
|
16305
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16306
|
+
{ _id: b._id },
|
|
16307
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16308
|
+
);
|
|
16309
|
+
await startBroadcastDispatch(b, ctx);
|
|
16310
|
+
}
|
|
16311
|
+
}
|
|
16312
|
+
async function runBroadcastDispatch(broadcast, ctx) {
|
|
16313
|
+
try {
|
|
16314
|
+
await dispatchBroadcast(broadcast, ctx);
|
|
16315
|
+
} catch (err) {
|
|
16316
|
+
console.error("mailery: broadcast dispatch failed", { id: String(broadcast._id), err });
|
|
16317
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16318
|
+
{ _id: broadcast._id },
|
|
16319
|
+
{ $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
|
|
16320
|
+
);
|
|
16066
16321
|
}
|
|
16067
16322
|
}
|
|
16068
16323
|
async function dispatchBroadcast(broadcast, ctx) {
|
|
@@ -16083,11 +16338,19 @@ async function dispatchBroadcast(broadcast, ctx) {
|
|
|
16083
16338
|
const respectTimezone = broadcast.respectRecipientTimezone === true;
|
|
16084
16339
|
const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
|
|
16085
16340
|
for (; ; ) {
|
|
16341
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16342
|
+
{ _id: broadcast._id },
|
|
16343
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16344
|
+
);
|
|
16086
16345
|
const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
|
|
16087
16346
|
if (page.contacts.length === 0) break;
|
|
16088
16347
|
const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
|
|
16089
16348
|
if (eligible.length > 0) {
|
|
16090
16349
|
while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
|
|
16350
|
+
await ctx.collections.broadcasts.updateOne(
|
|
16351
|
+
{ _id: broadcast._id },
|
|
16352
|
+
{ $set: { updatedAt: /* @__PURE__ */ new Date() } }
|
|
16353
|
+
);
|
|
16091
16354
|
await sleep(2e3);
|
|
16092
16355
|
}
|
|
16093
16356
|
const sendDocs = await Promise.all(
|
|
@@ -16210,7 +16473,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16210
16473
|
const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
|
|
16211
16474
|
let delayMs = Math.max(0, scheduledMs - Date.now());
|
|
16212
16475
|
if (respectTimezone && contact.timezone) {
|
|
16213
|
-
|
|
16476
|
+
const offsetMs = perRecipientOffsetMs(scheduledMs, contact.timezone);
|
|
16477
|
+
delayMs = Math.max(0, scheduledMs + offsetMs - Date.now());
|
|
16214
16478
|
}
|
|
16215
16479
|
const doc = {
|
|
16216
16480
|
_id: sendId,
|
|
@@ -16249,7 +16513,8 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
|
|
|
16249
16513
|
};
|
|
16250
16514
|
return { doc, delayMs };
|
|
16251
16515
|
}
|
|
16252
|
-
function
|
|
16516
|
+
function perRecipientOffsetMs(scheduledMs, timezone) {
|
|
16517
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
16253
16518
|
try {
|
|
16254
16519
|
const scheduled = new Date(scheduledMs);
|
|
16255
16520
|
const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
|
|
@@ -16262,7 +16527,7 @@ function perRecipientDelayMs(scheduledMs, timezone) {
|
|
|
16262
16527
|
const utcMs = parse(utc);
|
|
16263
16528
|
const localMs = parse(local);
|
|
16264
16529
|
const offsetMs = utcMs - localMs;
|
|
16265
|
-
return offsetMs;
|
|
16530
|
+
return (offsetMs % DAY_MS + DAY_MS) % DAY_MS;
|
|
16266
16531
|
} catch {
|
|
16267
16532
|
return 0;
|
|
16268
16533
|
}
|
|
@@ -16937,6 +17202,7 @@ async function pruneDmarcFailures(ctx, opts = {}) {
|
|
|
16937
17202
|
|
|
16938
17203
|
// src/server/runner/tick.ts
|
|
16939
17204
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
17205
|
+
var STRANDED_WEBHOOK_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
16940
17206
|
async function runTick(ctx) {
|
|
16941
17207
|
try {
|
|
16942
17208
|
await ctx.collections.health.updateOne(
|
|
@@ -16977,12 +17243,18 @@ async function runTick(ctx) {
|
|
|
16977
17243
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
16978
17244
|
console.error("mailery: broadcast dispatch failed", err);
|
|
16979
17245
|
});
|
|
17246
|
+
await resumeStalledBroadcasts(ctx).catch((err) => {
|
|
17247
|
+
console.error("mailery: stalled-broadcast resume failed", err);
|
|
17248
|
+
});
|
|
16980
17249
|
await evaluateHealth(ctx).catch((err) => {
|
|
16981
17250
|
console.error("mailery: health evaluation failed", err);
|
|
16982
17251
|
});
|
|
16983
17252
|
await promoteSoftBounces(ctx).catch((err) => {
|
|
16984
17253
|
console.error("mailery: soft-bounce promotion failed", err);
|
|
16985
17254
|
});
|
|
17255
|
+
await processWebhookBacklog(ctx, { olderThanMs: STRANDED_WEBHOOK_THRESHOLD_MS }).catch((err) => {
|
|
17256
|
+
console.error("mailery: stranded-webhook drain failed", err);
|
|
17257
|
+
});
|
|
16986
17258
|
await Promise.all([
|
|
16987
17259
|
runDnsblChecks(ctx).catch((err) => {
|
|
16988
17260
|
console.error("mailery: dnsbl checks failed", err);
|
|
@@ -17053,140 +17325,6 @@ async function processScheduledBroadcasts2(ctx) {
|
|
|
17053
17325
|
await processScheduledBroadcasts(ctx);
|
|
17054
17326
|
}
|
|
17055
17327
|
|
|
17056
|
-
// src/server/runner/webhook.ts
|
|
17057
|
-
function dimsFromSend(send) {
|
|
17058
|
-
if (!send) return null;
|
|
17059
|
-
return { fromEmail: send.fromEmail, kind: send.kind };
|
|
17060
|
-
}
|
|
17061
|
-
async function applyWebhookEvent(event, ctx) {
|
|
17062
|
-
const send = await ctx.collections.sends.findOne(
|
|
17063
|
-
event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
|
|
17064
|
-
{ sort: { queuedAt: -1 } }
|
|
17065
|
-
);
|
|
17066
|
-
switch (event.type) {
|
|
17067
|
-
case "delivered":
|
|
17068
|
-
if (send) {
|
|
17069
|
-
await ctx.collections.sends.updateOne(
|
|
17070
|
-
{ _id: send._id },
|
|
17071
|
-
{ $set: { status: "delivered", deliveredAt: event.occurredAt } }
|
|
17072
|
-
);
|
|
17073
|
-
}
|
|
17074
|
-
await recordHealthCounter(ctx, "delivered", dimsFromSend(send));
|
|
17075
|
-
break;
|
|
17076
|
-
case "open":
|
|
17077
|
-
if (send) {
|
|
17078
|
-
await ctx.collections.sends.updateOne(
|
|
17079
|
-
{ _id: send._id },
|
|
17080
|
-
{
|
|
17081
|
-
$set: {
|
|
17082
|
-
openedAt: send.openedAt ?? event.occurredAt,
|
|
17083
|
-
status: send.status === "sent" ? "delivered" : send.status
|
|
17084
|
-
},
|
|
17085
|
-
$inc: { openCount: 1 }
|
|
17086
|
-
}
|
|
17087
|
-
);
|
|
17088
|
-
}
|
|
17089
|
-
break;
|
|
17090
|
-
case "click":
|
|
17091
|
-
if (send) {
|
|
17092
|
-
await ctx.collections.sends.updateOne(
|
|
17093
|
-
{ _id: send._id },
|
|
17094
|
-
{
|
|
17095
|
-
$set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
|
|
17096
|
-
$inc: { clickCount: 1 },
|
|
17097
|
-
$push: {
|
|
17098
|
-
clickedLinks: {
|
|
17099
|
-
url: event.details.clickedUrl ?? "",
|
|
17100
|
-
linkId: "",
|
|
17101
|
-
clickedAt: event.occurredAt
|
|
17102
|
-
}
|
|
17103
|
-
}
|
|
17104
|
-
}
|
|
17105
|
-
);
|
|
17106
|
-
}
|
|
17107
|
-
break;
|
|
17108
|
-
case "bounce": {
|
|
17109
|
-
const bounceType = event.details.bounceType ?? "hard";
|
|
17110
|
-
if (send) {
|
|
17111
|
-
await ctx.collections.sends.updateOne(
|
|
17112
|
-
{ _id: send._id },
|
|
17113
|
-
{
|
|
17114
|
-
$set: {
|
|
17115
|
-
status: "bounced",
|
|
17116
|
-
bounceType,
|
|
17117
|
-
bounceReason: event.details.bounceReason ?? null
|
|
17118
|
-
}
|
|
17119
|
-
}
|
|
17120
|
-
);
|
|
17121
|
-
}
|
|
17122
|
-
if (bounceType === "hard") {
|
|
17123
|
-
await suppressOnce(ctx, event.email, "hard_bounce", "all");
|
|
17124
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17125
|
-
{ emailAtSubscribe: event.email },
|
|
17126
|
-
{ $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17127
|
-
);
|
|
17128
|
-
}
|
|
17129
|
-
{
|
|
17130
|
-
const dims = dimsFromSend(send);
|
|
17131
|
-
await recordHealthCounter(ctx, "bounced", dims);
|
|
17132
|
-
await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced", dims);
|
|
17133
|
-
}
|
|
17134
|
-
break;
|
|
17135
|
-
}
|
|
17136
|
-
case "complaint":
|
|
17137
|
-
case "spam_report":
|
|
17138
|
-
if (send) {
|
|
17139
|
-
await ctx.collections.sends.updateOne(
|
|
17140
|
-
{ _id: send._id },
|
|
17141
|
-
{ $set: { complainedAt: event.occurredAt, status: "complained" } }
|
|
17142
|
-
);
|
|
17143
|
-
}
|
|
17144
|
-
await suppressOnce(ctx, event.email, "complaint", "all");
|
|
17145
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17146
|
-
{ emailAtSubscribe: event.email },
|
|
17147
|
-
{ $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
|
|
17148
|
-
);
|
|
17149
|
-
await recordHealthCounter(ctx, "complained", dimsFromSend(send));
|
|
17150
|
-
break;
|
|
17151
|
-
case "unsubscribe":
|
|
17152
|
-
if (send) {
|
|
17153
|
-
await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
|
|
17154
|
-
}
|
|
17155
|
-
await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
|
|
17156
|
-
await ctx.collections.subscriptions.updateOne(
|
|
17157
|
-
{ emailAtSubscribe: event.email },
|
|
17158
|
-
{
|
|
17159
|
-
$set: {
|
|
17160
|
-
status: "unsubscribed",
|
|
17161
|
-
unsubscribedAt: event.occurredAt,
|
|
17162
|
-
unsubscribeReason: "user_request",
|
|
17163
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
17164
|
-
}
|
|
17165
|
-
}
|
|
17166
|
-
);
|
|
17167
|
-
break;
|
|
17168
|
-
}
|
|
17169
|
-
}
|
|
17170
|
-
async function suppressOnce(ctx, email, reason, scope) {
|
|
17171
|
-
const normalized = email.toLowerCase();
|
|
17172
|
-
await ctx.collections.suppressions.updateOne(
|
|
17173
|
-
{ email: normalized, scope },
|
|
17174
|
-
{
|
|
17175
|
-
$setOnInsert: {
|
|
17176
|
-
email: normalized,
|
|
17177
|
-
emailHash: sha256Hex(normalized),
|
|
17178
|
-
scope,
|
|
17179
|
-
reason,
|
|
17180
|
-
source: "provider_webhook",
|
|
17181
|
-
notes: null,
|
|
17182
|
-
addedAt: /* @__PURE__ */ new Date(),
|
|
17183
|
-
expiresAt: null
|
|
17184
|
-
}
|
|
17185
|
-
},
|
|
17186
|
-
{ upsert: true }
|
|
17187
|
-
);
|
|
17188
|
-
}
|
|
17189
|
-
|
|
17190
17328
|
// src/server/mailer.ts
|
|
17191
17329
|
var Mailer = class _Mailer {
|
|
17192
17330
|
db;
|
|
@@ -17651,41 +17789,48 @@ var Mailer = class _Mailer {
|
|
|
17651
17789
|
if (existing) return { sendId: String(existing._id) };
|
|
17652
17790
|
const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
|
|
17653
17791
|
const sendId = new mongodb.ObjectId();
|
|
17654
|
-
|
|
17655
|
-
|
|
17656
|
-
|
|
17657
|
-
|
|
17658
|
-
|
|
17659
|
-
|
|
17660
|
-
|
|
17661
|
-
|
|
17662
|
-
|
|
17663
|
-
|
|
17664
|
-
|
|
17665
|
-
|
|
17666
|
-
|
|
17667
|
-
|
|
17668
|
-
|
|
17669
|
-
|
|
17670
|
-
|
|
17671
|
-
|
|
17672
|
-
|
|
17673
|
-
|
|
17674
|
-
|
|
17675
|
-
|
|
17676
|
-
|
|
17677
|
-
|
|
17678
|
-
|
|
17679
|
-
|
|
17680
|
-
|
|
17681
|
-
|
|
17682
|
-
|
|
17683
|
-
|
|
17684
|
-
|
|
17685
|
-
|
|
17686
|
-
|
|
17687
|
-
|
|
17688
|
-
|
|
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
|
+
}
|
|
17689
17834
|
await this.queues.send.add(
|
|
17690
17835
|
"send",
|
|
17691
17836
|
{ sendId: String(sendId) },
|
|
@@ -17729,7 +17874,12 @@ var Mailer = class _Mailer {
|
|
|
17729
17874
|
await runTick(this.runnerContext);
|
|
17730
17875
|
},
|
|
17731
17876
|
advance: async (data) => {
|
|
17732
|
-
if (
|
|
17877
|
+
if (data.broadcastId) {
|
|
17878
|
+
if (!mongodb.ObjectId.isValid(data.broadcastId)) return;
|
|
17879
|
+
await dispatchBroadcastById(new mongodb.ObjectId(data.broadcastId), this.runnerContext);
|
|
17880
|
+
return;
|
|
17881
|
+
}
|
|
17882
|
+
if (!data.flowRunId || !mongodb.ObjectId.isValid(data.flowRunId)) return;
|
|
17733
17883
|
await processOneRunStep(new mongodb.ObjectId(data.flowRunId), this.runnerContext);
|
|
17734
17884
|
},
|
|
17735
17885
|
send: async (data) => {
|
|
@@ -17745,27 +17895,7 @@ var Mailer = class _Mailer {
|
|
|
17745
17895
|
}
|
|
17746
17896
|
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
17747
17897
|
async processWebhookBacklog() {
|
|
17748
|
-
|
|
17749
|
-
for (const evt of batch) {
|
|
17750
|
-
try {
|
|
17751
|
-
const normalized = evt.raw?.normalized;
|
|
17752
|
-
const details = normalized?.details ?? {};
|
|
17753
|
-
await applyWebhookEvent(
|
|
17754
|
-
{
|
|
17755
|
-
type: evt.normalizedType,
|
|
17756
|
-
providerEventId: evt.providerEventId,
|
|
17757
|
-
providerMessageId: evt.providerMessageId,
|
|
17758
|
-
email: evt.email,
|
|
17759
|
-
occurredAt: evt.occurredAt,
|
|
17760
|
-
details
|
|
17761
|
-
},
|
|
17762
|
-
this.runnerContext
|
|
17763
|
-
);
|
|
17764
|
-
await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
|
|
17765
|
-
} catch (err) {
|
|
17766
|
-
console.error("mailery: webhook apply failed", { id: String(evt._id), err });
|
|
17767
|
-
}
|
|
17768
|
-
}
|
|
17898
|
+
await processWebhookBacklog(this.runnerContext);
|
|
17769
17899
|
}
|
|
17770
17900
|
async stop() {
|
|
17771
17901
|
await this.queueDriver.close();
|
|
@@ -17852,6 +17982,207 @@ function matches(c, f) {
|
|
|
17852
17982
|
return true;
|
|
17853
17983
|
}
|
|
17854
17984
|
|
|
17985
|
+
// src/testing/recording-provider.ts
|
|
17986
|
+
var RecordingProvider = class {
|
|
17987
|
+
constructor(inner) {
|
|
17988
|
+
this.inner = inner;
|
|
17989
|
+
}
|
|
17990
|
+
inner;
|
|
17991
|
+
/** Every `SendArgs` handed to the provider, in call order. */
|
|
17992
|
+
sent = [];
|
|
17993
|
+
/** Same calls, with outcome attached. */
|
|
17994
|
+
records = [];
|
|
17995
|
+
get name() {
|
|
17996
|
+
return this.inner.name;
|
|
17997
|
+
}
|
|
17998
|
+
get sendRatePerSecond() {
|
|
17999
|
+
return this.inner.sendRatePerSecond;
|
|
18000
|
+
}
|
|
18001
|
+
async send(args) {
|
|
18002
|
+
this.sent.push(args);
|
|
18003
|
+
const record = { args, result: null, error: null, durationMs: 0, at: /* @__PURE__ */ new Date() };
|
|
18004
|
+
this.records.push(record);
|
|
18005
|
+
const startedAt = Date.now();
|
|
18006
|
+
try {
|
|
18007
|
+
const result = await this.inner.send(args);
|
|
18008
|
+
record.result = result;
|
|
18009
|
+
return result;
|
|
18010
|
+
} catch (err) {
|
|
18011
|
+
record.error = err instanceof Error ? err : new Error(String(err));
|
|
18012
|
+
throw err;
|
|
18013
|
+
} finally {
|
|
18014
|
+
record.durationMs = Date.now() - startedAt;
|
|
18015
|
+
}
|
|
18016
|
+
}
|
|
18017
|
+
async verifyWebhook(rawBody, headers) {
|
|
18018
|
+
return this.inner.verifyWebhook(rawBody, headers);
|
|
18019
|
+
}
|
|
18020
|
+
parseWebhookEvents(payload, headers) {
|
|
18021
|
+
return this.inner.parseWebhookEvents(payload, headers);
|
|
18022
|
+
}
|
|
18023
|
+
/** Last recorded send, or undefined. Sugar for the common single-send assertion. */
|
|
18024
|
+
get last() {
|
|
18025
|
+
return this.sent[this.sent.length - 1];
|
|
18026
|
+
}
|
|
18027
|
+
/** All sends addressed to `email` (case-insensitive). */
|
|
18028
|
+
toRecipient(email) {
|
|
18029
|
+
const lower = email.toLowerCase();
|
|
18030
|
+
return this.sent.filter((s) => s.to.toLowerCase() === lower);
|
|
18031
|
+
}
|
|
18032
|
+
reset() {
|
|
18033
|
+
this.sent.length = 0;
|
|
18034
|
+
this.records.length = 0;
|
|
18035
|
+
}
|
|
18036
|
+
};
|
|
18037
|
+
|
|
18038
|
+
// src/testing/builders.ts
|
|
18039
|
+
var BACKDATE_MS = 6e4;
|
|
18040
|
+
async function buildTemplate(spec) {
|
|
18041
|
+
const now = /* @__PURE__ */ new Date();
|
|
18042
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
18043
|
+
let mjml = "";
|
|
18044
|
+
let html;
|
|
18045
|
+
if (spec.html) {
|
|
18046
|
+
html = spec.html;
|
|
18047
|
+
} else {
|
|
18048
|
+
mjml = spec.mjml ?? wrapMjml(spec.text ?? "Hello {{contact.fields.firstName}}");
|
|
18049
|
+
const compiled = await compileTemplate(mjml);
|
|
18050
|
+
html = compiled.html;
|
|
18051
|
+
}
|
|
18052
|
+
const plainText = spec.plainText ?? derivePlaintext(html);
|
|
18053
|
+
return {
|
|
18054
|
+
slug: spec.slug,
|
|
18055
|
+
name: spec.name ?? spec.slug,
|
|
18056
|
+
description: spec.description ?? "",
|
|
18057
|
+
kind: spec.kind ?? "marketing",
|
|
18058
|
+
fromName: spec.fromName ?? "Test",
|
|
18059
|
+
fromEmail: spec.fromEmail ?? "hello@example.com",
|
|
18060
|
+
replyTo: spec.replyTo ?? null,
|
|
18061
|
+
providerOverride: spec.providerOverride ?? null,
|
|
18062
|
+
subject: spec.subject ?? "Test subject",
|
|
18063
|
+
preheader: spec.preheader ?? "",
|
|
18064
|
+
body: { mjml, editorJson: null, html, plainText, compiledAt: createdAt },
|
|
18065
|
+
variablesSchema: spec.variablesSchema ?? {},
|
|
18066
|
+
draft: null,
|
|
18067
|
+
tags: spec.tags ?? [],
|
|
18068
|
+
trackOpens: spec.trackOpens ?? false,
|
|
18069
|
+
trackClicks: spec.trackClicks ?? false,
|
|
18070
|
+
stats: {
|
|
18071
|
+
sent: 0,
|
|
18072
|
+
delivered: 0,
|
|
18073
|
+
opened: 0,
|
|
18074
|
+
clicked: 0,
|
|
18075
|
+
bounced: 0,
|
|
18076
|
+
complained: 0,
|
|
18077
|
+
unsubscribed: 0,
|
|
18078
|
+
lastSentAt: null
|
|
18079
|
+
},
|
|
18080
|
+
publishedAt: spec.published === false ? null : createdAt,
|
|
18081
|
+
publishedBy: spec.published === false ? null : "test",
|
|
18082
|
+
createdAt,
|
|
18083
|
+
updatedAt: createdAt
|
|
18084
|
+
};
|
|
18085
|
+
}
|
|
18086
|
+
function wrapMjml(body) {
|
|
18087
|
+
return `<mjml><mj-body><mj-section><mj-column><mj-text>${body}</mj-text></mj-column></mj-section></mj-body></mjml>`;
|
|
18088
|
+
}
|
|
18089
|
+
function buildFlow(spec) {
|
|
18090
|
+
const now = /* @__PURE__ */ new Date();
|
|
18091
|
+
const createdAt = spec.createdAt ?? new Date(now.getTime() - BACKDATE_MS);
|
|
18092
|
+
const trigger = spec.trigger ?? {
|
|
18093
|
+
type: "event",
|
|
18094
|
+
eventName: spec.eventName ?? `${spec.slug}:trigger`,
|
|
18095
|
+
once: spec.once ?? true
|
|
18096
|
+
};
|
|
18097
|
+
return {
|
|
18098
|
+
slug: spec.slug,
|
|
18099
|
+
name: spec.name ?? spec.slug,
|
|
18100
|
+
description: spec.description ?? "",
|
|
18101
|
+
trigger,
|
|
18102
|
+
enabled: spec.enabled ?? true,
|
|
18103
|
+
steps: spec.steps,
|
|
18104
|
+
version: spec.version ?? 1,
|
|
18105
|
+
draft: null,
|
|
18106
|
+
goal: spec.goal ?? "activation",
|
|
18107
|
+
audience: spec.audience ?? "test",
|
|
18108
|
+
expectedVolumePerWeek: null,
|
|
18109
|
+
stats: { activeRuns: 0, completedRuns: 0, sendsTotal: 0, sendsLast7Days: 0 },
|
|
18110
|
+
lastTriggerScanAt: null,
|
|
18111
|
+
publishedAt: createdAt,
|
|
18112
|
+
publishedBy: "test",
|
|
18113
|
+
createdAt,
|
|
18114
|
+
updatedAt: createdAt
|
|
18115
|
+
};
|
|
18116
|
+
}
|
|
18117
|
+
var step = {
|
|
18118
|
+
send(templateSlug, opts = {}) {
|
|
18119
|
+
return { type: "send", templateSlug, ...opts };
|
|
18120
|
+
},
|
|
18121
|
+
sendAt(templateSlug, delivery) {
|
|
18122
|
+
return { type: "send", templateSlug, delivery };
|
|
18123
|
+
},
|
|
18124
|
+
wait(value, unit = "minutes") {
|
|
18125
|
+
return { type: "wait", value, unit };
|
|
18126
|
+
},
|
|
18127
|
+
tag(addTags, removeTags) {
|
|
18128
|
+
return { type: "tag", addTags, removeTags };
|
|
18129
|
+
},
|
|
18130
|
+
exit(reason) {
|
|
18131
|
+
return { type: "exit", reason };
|
|
18132
|
+
},
|
|
18133
|
+
fireEvent(eventName, properties) {
|
|
18134
|
+
return { type: "fire_event", eventName, properties };
|
|
18135
|
+
}
|
|
18136
|
+
};
|
|
18137
|
+
|
|
18138
|
+
// src/testing/drive.ts
|
|
18139
|
+
async function drain(ctx, opts = {}) {
|
|
18140
|
+
const maxRounds = opts.maxRounds ?? 100;
|
|
18141
|
+
const shouldDispatch = opts.dispatch !== false;
|
|
18142
|
+
const attempted = /* @__PURE__ */ new Set();
|
|
18143
|
+
const errors = [];
|
|
18144
|
+
let dispatched = 0;
|
|
18145
|
+
let rounds = 0;
|
|
18146
|
+
while (rounds < maxRounds) {
|
|
18147
|
+
rounds++;
|
|
18148
|
+
await processNewlyFiredEventTriggers(ctx);
|
|
18149
|
+
await sweepStrandedFlowRuns(ctx);
|
|
18150
|
+
let didWork = false;
|
|
18151
|
+
if (shouldDispatch) {
|
|
18152
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18153
|
+
for (const row of queued) {
|
|
18154
|
+
const id = String(row._id);
|
|
18155
|
+
if (attempted.has(id)) continue;
|
|
18156
|
+
attempted.add(id);
|
|
18157
|
+
didWork = true;
|
|
18158
|
+
dispatched++;
|
|
18159
|
+
try {
|
|
18160
|
+
await dispatchSend(row._id, ctx);
|
|
18161
|
+
} catch (err) {
|
|
18162
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
18163
|
+
if (opts.throwOnSendError) throw err;
|
|
18164
|
+
}
|
|
18165
|
+
}
|
|
18166
|
+
}
|
|
18167
|
+
const due = await ctx.collections.flowRuns.countDocuments({
|
|
18168
|
+
status: "active",
|
|
18169
|
+
nextActionAt: { $lte: /* @__PURE__ */ new Date() }
|
|
18170
|
+
});
|
|
18171
|
+
if (!didWork && due === 0) {
|
|
18172
|
+
return { rounds, dispatched, errors, settled: true };
|
|
18173
|
+
}
|
|
18174
|
+
}
|
|
18175
|
+
return { rounds, dispatched, errors, settled: false };
|
|
18176
|
+
}
|
|
18177
|
+
async function dispatchQueued(ctx) {
|
|
18178
|
+
const queued = await ctx.collections.sends.find({ status: "queued" }, { projection: { _id: 1 } }).toArray();
|
|
18179
|
+
for (const row of queued) {
|
|
18180
|
+
await dispatchSend(row._id, ctx).catch(() => {
|
|
18181
|
+
});
|
|
18182
|
+
}
|
|
18183
|
+
return queued.length;
|
|
18184
|
+
}
|
|
18185
|
+
|
|
17855
18186
|
// src/testing/index.ts
|
|
17856
18187
|
async function createTestMailer(opts = {}) {
|
|
17857
18188
|
const { MongoMemoryServer } = await Promise.resolve().then(() => __toESM(require_mongodb_memory_server()));
|
|
@@ -17861,13 +18192,13 @@ async function createTestMailer(opts = {}) {
|
|
|
17861
18192
|
const db = client.db("mailery-test");
|
|
17862
18193
|
const memoryAdapter = opts.adapter ? null : new MemoryContactAdapter(opts.seedContacts ?? []);
|
|
17863
18194
|
const adapter = opts.adapter ?? memoryAdapter;
|
|
17864
|
-
const provider = opts.provider ??
|
|
18195
|
+
const provider = new RecordingProvider(await resolveProvider(opts.provider ?? "null"));
|
|
17865
18196
|
const mailer = await Mailer.init({
|
|
17866
18197
|
db,
|
|
17867
18198
|
adapter,
|
|
17868
|
-
queue: { driver: "noop" },
|
|
17869
|
-
providers: {
|
|
17870
|
-
defaultProvider:
|
|
18199
|
+
queue: opts.queue ?? { driver: "noop" },
|
|
18200
|
+
providers: { [provider.name]: provider },
|
|
18201
|
+
defaultProvider: provider.name,
|
|
17871
18202
|
publicUrl: "http://localhost:3000",
|
|
17872
18203
|
unsubscribeSecret: "test-unsub-secret-32-bytes-or-more-please",
|
|
17873
18204
|
senderAddress: "1 Test St, Brooklyn NY 11201",
|
|
@@ -17875,19 +18206,81 @@ async function createTestMailer(opts = {}) {
|
|
|
17875
18206
|
workerless: true,
|
|
17876
18207
|
...opts.config ?? {}
|
|
17877
18208
|
});
|
|
17878
|
-
|
|
17879
|
-
|
|
17880
|
-
|
|
17881
|
-
|
|
17882
|
-
|
|
17883
|
-
|
|
17884
|
-
|
|
18209
|
+
if (opts.startWorkers) await mailer.startWorkers();
|
|
18210
|
+
const ctx = mailer.getRunnerContext();
|
|
18211
|
+
const harness = {
|
|
18212
|
+
mailer,
|
|
18213
|
+
db,
|
|
18214
|
+
provider,
|
|
18215
|
+
adapter,
|
|
18216
|
+
memoryAdapter,
|
|
18217
|
+
ctx,
|
|
18218
|
+
async seedContact(contact, seedOpts = {}) {
|
|
18219
|
+
if (!memoryAdapter) {
|
|
18220
|
+
throw new Error("seedContact requires the built-in MemoryContactAdapter \u2014 you passed a custom `adapter`.");
|
|
18221
|
+
}
|
|
18222
|
+
memoryAdapter.upsert(contact);
|
|
18223
|
+
if (seedOpts.subscribe !== false) {
|
|
18224
|
+
await mailer.upsertSubscription({
|
|
18225
|
+
externalId: contact.externalId,
|
|
18226
|
+
source: seedOpts.source ?? "test"
|
|
18227
|
+
});
|
|
18228
|
+
}
|
|
18229
|
+
return contact;
|
|
18230
|
+
},
|
|
18231
|
+
async seedTemplate(spec) {
|
|
18232
|
+
const doc = await buildTemplate(spec);
|
|
18233
|
+
const res = await ctx.collections.templates.insertOne(doc);
|
|
18234
|
+
doc._id = res.insertedId;
|
|
18235
|
+
return doc;
|
|
18236
|
+
},
|
|
18237
|
+
async seedFlow(spec) {
|
|
18238
|
+
const doc = buildFlow(spec);
|
|
18239
|
+
const res = await ctx.collections.flows.insertOne(doc);
|
|
18240
|
+
doc._id = res.insertedId;
|
|
18241
|
+
return doc;
|
|
18242
|
+
},
|
|
18243
|
+
drain(drainOpts) {
|
|
18244
|
+
return drain(ctx, drainOpts);
|
|
18245
|
+
},
|
|
18246
|
+
stop: async () => {
|
|
18247
|
+
await mailer.stop().catch(() => {
|
|
18248
|
+
});
|
|
18249
|
+
await client.close().catch(() => {
|
|
18250
|
+
});
|
|
18251
|
+
await mongo.stop().catch(() => {
|
|
18252
|
+
});
|
|
18253
|
+
}
|
|
17885
18254
|
};
|
|
17886
|
-
return
|
|
18255
|
+
return harness;
|
|
18256
|
+
}
|
|
18257
|
+
async function resolveProvider(spec) {
|
|
18258
|
+
if (typeof spec !== "string") return spec;
|
|
18259
|
+
if (spec === "null") return new NullProvider();
|
|
18260
|
+
const apiKey = process.env.SENDGRID_API_KEY;
|
|
18261
|
+
if (!apiKey) {
|
|
18262
|
+
throw new Error(
|
|
18263
|
+
"createTestMailer({ provider: 'sendgrid' }) requires SENDGRID_API_KEY. Live tests should gate on it before constructing the harness."
|
|
18264
|
+
);
|
|
18265
|
+
}
|
|
18266
|
+
const { SendGridProvider: SendGridProvider2 } = await Promise.resolve().then(() => (init_sendgrid(), sendgrid_exports));
|
|
18267
|
+
return new SendGridProvider2({
|
|
18268
|
+
apiKey,
|
|
18269
|
+
// Sandbox unless explicitly told to deliver: SendGrid still authenticates
|
|
18270
|
+
// and validates the payload, it just never puts anything in an inbox.
|
|
18271
|
+
sandbox: process.env.MAILERY_LIVE_E2E !== "deliver"
|
|
18272
|
+
});
|
|
17887
18273
|
}
|
|
17888
18274
|
|
|
17889
18275
|
exports.MemoryContactAdapter = MemoryContactAdapter;
|
|
17890
18276
|
exports.NullProvider = NullProvider;
|
|
18277
|
+
exports.RecordingProvider = RecordingProvider;
|
|
18278
|
+
exports.buildFlow = buildFlow;
|
|
18279
|
+
exports.buildTemplate = buildTemplate;
|
|
17891
18280
|
exports.createTestMailer = createTestMailer;
|
|
18281
|
+
exports.dispatchQueued = dispatchQueued;
|
|
18282
|
+
exports.drain = drain;
|
|
18283
|
+
exports.step = step;
|
|
18284
|
+
exports.wrapMjml = wrapMjml;
|
|
17892
18285
|
//# sourceMappingURL=testing.cjs.map
|
|
17893
18286
|
//# sourceMappingURL=testing.cjs.map
|