mailery 0.15.0 → 0.16.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/README.md +1 -0
- package/dist/index.cjs +1407 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +428 -115
- package/dist/index.d.ts +428 -115
- package/dist/index.js +1393 -14
- package/dist/index.js.map +1 -1
- package/dist/{null-CnhsvKvy.d.cts → null-CDlseQxO.d.cts} +1 -1
- package/dist/{null-CnhsvKvy.d.ts → null-CDlseQxO.d.ts} +1 -1
- package/dist/testing.cjs +37 -4
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +37 -4
- package/dist/testing.js.map +1 -1
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -286,8 +286,36 @@ var init_webhook_tolerance = __esm({
|
|
|
286
286
|
// src/server/providers/sendgrid.ts
|
|
287
287
|
var sendgrid_exports = {};
|
|
288
288
|
__export(sendgrid_exports, {
|
|
289
|
-
SendGridProvider: () => exports.SendGridProvider
|
|
289
|
+
SendGridProvider: () => exports.SendGridProvider,
|
|
290
|
+
normalizeWebhookVerificationKey: () => normalizeWebhookVerificationKey
|
|
290
291
|
});
|
|
292
|
+
function normalizeWebhookVerificationKey(input) {
|
|
293
|
+
let key = String(input ?? "").trim().replace(/^["']|["']$/g, "");
|
|
294
|
+
if (key.includes("\\n")) key = key.replace(/\\n/g, "\n");
|
|
295
|
+
let pem;
|
|
296
|
+
if (key.includes(PEM_HEADER)) {
|
|
297
|
+
pem = key;
|
|
298
|
+
} else {
|
|
299
|
+
const b64 = key.replace(/\s+/g, "");
|
|
300
|
+
if (!b64 || !/^[A-Za-z0-9+/=]+$/.test(b64)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"SendGridProvider: webhookVerificationKey is neither a PEM public key nor a base64 SPKI key. Copy the Verification Key from SendGrid \u2192 Settings \u2192 Mail Settings \u2192 Signed Event Webhook."
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
pem = `${PEM_HEADER}
|
|
306
|
+
${b64.match(/.{1,64}/g).join("\n")}
|
|
307
|
+
${PEM_FOOTER}
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
crypto2__default.default.createPublicKey(pem);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`SendGridProvider: webhookVerificationKey did not parse as a public key (${String(err?.message ?? err)}). Copy the Verification Key from SendGrid \u2192 Settings \u2192 Mail Settings \u2192 Signed Event Webhook.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return pem;
|
|
318
|
+
}
|
|
291
319
|
function normalizeSendGridEvent(e) {
|
|
292
320
|
const type = mapEventType(e.event);
|
|
293
321
|
if (!type) return null;
|
|
@@ -326,24 +354,29 @@ function mapEventType(sgEvent) {
|
|
|
326
354
|
return null;
|
|
327
355
|
}
|
|
328
356
|
}
|
|
329
|
-
var SG_SIG_HEADER, SG_TS_HEADER; exports.SendGridProvider = void 0;
|
|
357
|
+
var SG_SIG_HEADER, SG_TS_HEADER, PEM_HEADER, PEM_FOOTER; exports.SendGridProvider = void 0;
|
|
330
358
|
var init_sendgrid = __esm({
|
|
331
359
|
"src/server/providers/sendgrid.ts"() {
|
|
332
360
|
init_webhook_tolerance();
|
|
333
361
|
SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
|
|
334
362
|
SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
|
|
363
|
+
PEM_HEADER = "-----BEGIN PUBLIC KEY-----";
|
|
364
|
+
PEM_FOOTER = "-----END PUBLIC KEY-----";
|
|
335
365
|
exports.SendGridProvider = class {
|
|
336
366
|
constructor(opts) {
|
|
337
367
|
this.opts = opts;
|
|
338
368
|
sgMail__default.default.setApiKey(opts.apiKey);
|
|
339
369
|
this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
|
|
340
370
|
this.webhookToleranceSeconds = resolveWebhookToleranceSeconds(opts.webhookToleranceSeconds);
|
|
371
|
+
this.verificationKeyPem = opts.webhookVerificationKey ? normalizeWebhookVerificationKey(opts.webhookVerificationKey) : null;
|
|
341
372
|
}
|
|
342
373
|
opts;
|
|
343
374
|
name = "sendgrid";
|
|
344
375
|
sendRatePerSecond;
|
|
345
376
|
/** Resolved replay window in seconds; `0` means the check is disabled. */
|
|
346
377
|
webhookToleranceSeconds;
|
|
378
|
+
/** The verification key as PEM, whatever shape it was configured in. */
|
|
379
|
+
verificationKeyPem;
|
|
347
380
|
async send(args) {
|
|
348
381
|
const msg = {
|
|
349
382
|
to: args.to,
|
|
@@ -375,7 +408,7 @@ var init_sendgrid = __esm({
|
|
|
375
408
|
};
|
|
376
409
|
}
|
|
377
410
|
async verifyWebhook(rawBody, headers) {
|
|
378
|
-
if (!this.
|
|
411
|
+
if (!this.verificationKeyPem) return false;
|
|
379
412
|
const sig = headers[SG_SIG_HEADER];
|
|
380
413
|
const ts = headers[SG_TS_HEADER];
|
|
381
414
|
if (!sig || !ts) return false;
|
|
@@ -383,7 +416,7 @@ var init_sendgrid = __esm({
|
|
|
383
416
|
try {
|
|
384
417
|
const verifier = crypto2__default.default.createVerify("sha256");
|
|
385
418
|
verifier.update(payload);
|
|
386
|
-
if (!verifier.verify(this.
|
|
419
|
+
if (!verifier.verify(this.verificationKeyPem, sig, "base64")) return false;
|
|
387
420
|
} catch {
|
|
388
421
|
return false;
|
|
389
422
|
}
|
|
@@ -6180,13 +6213,13 @@ function createAdminRouter(mailer, opts = {}) {
|
|
|
6180
6213
|
req.actor = getActor(req);
|
|
6181
6214
|
next();
|
|
6182
6215
|
});
|
|
6183
|
-
router.use("/api",
|
|
6216
|
+
router.use("/api", createAdminApiRouter(mailer, opts));
|
|
6184
6217
|
router.get(/.*/, (_req, res) => {
|
|
6185
6218
|
res.sendFile(path__default.default.join(spaDir, "index.html"));
|
|
6186
6219
|
});
|
|
6187
6220
|
return router;
|
|
6188
6221
|
}
|
|
6189
|
-
function
|
|
6222
|
+
function createAdminApiRouter(mailer, opts = {}) {
|
|
6190
6223
|
const r = express.Router();
|
|
6191
6224
|
const c = mailer.collections;
|
|
6192
6225
|
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
@@ -6383,7 +6416,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6383
6416
|
asyncHandler(async (req, res) => {
|
|
6384
6417
|
const before = await c.flows.findOne({ slug: req.params.slug });
|
|
6385
6418
|
if (!before) return res.status(404).json({ error: "not_found" });
|
|
6386
|
-
|
|
6419
|
+
const now = /* @__PURE__ */ new Date();
|
|
6420
|
+
await c.flows.updateOne(
|
|
6421
|
+
{ _id: before._id },
|
|
6422
|
+
{ $set: { enabled: true, updatedAt: now, ...before.lastTriggerScanAt ? {} : { lastTriggerScanAt: now } } }
|
|
6423
|
+
);
|
|
6387
6424
|
await mailer.audit({
|
|
6388
6425
|
actor: req.actor,
|
|
6389
6426
|
action: "flow.resume",
|
|
@@ -6964,6 +7001,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6964
7001
|
if (!Array.isArray(draftSteps) || draftSteps.length === 0) {
|
|
6965
7002
|
return res.status(400).json({ error: "empty_flow", message: "flow has no steps to publish" });
|
|
6966
7003
|
}
|
|
7004
|
+
const enable = req.body?.enable !== false;
|
|
6967
7005
|
const nextVersion = (flow.version ?? 0) + 1;
|
|
6968
7006
|
const now = /* @__PURE__ */ new Date();
|
|
6969
7007
|
await c.flowVersions.insertOne({
|
|
@@ -6980,11 +7018,14 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6980
7018
|
$set: {
|
|
6981
7019
|
steps: draftSteps,
|
|
6982
7020
|
version: nextVersion,
|
|
6983
|
-
enabled: true,
|
|
7021
|
+
enabled: enable ? true : flow.enabled,
|
|
6984
7022
|
draft: null,
|
|
6985
7023
|
publishedAt: now,
|
|
6986
7024
|
publishedBy: req.actor,
|
|
6987
|
-
updatedAt: now
|
|
7025
|
+
updatedAt: now,
|
|
7026
|
+
// First enable: stamp the trigger watermark so the scan does not
|
|
7027
|
+
// replay every event since the flow document was created.
|
|
7028
|
+
...enable && !flow.lastTriggerScanAt ? { lastTriggerScanAt: now } : {}
|
|
6988
7029
|
}
|
|
6989
7030
|
}
|
|
6990
7031
|
);
|
|
@@ -6992,9 +7033,9 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6992
7033
|
actor: req.actor,
|
|
6993
7034
|
action: "flow.publish",
|
|
6994
7035
|
resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
|
|
6995
|
-
diffSummary: `Published v${nextVersion}`
|
|
7036
|
+
diffSummary: `Published v${nextVersion}${enable ? "" : " (left disabled)"}`
|
|
6996
7037
|
});
|
|
6997
|
-
return res.json({ ok: true, version: nextVersion });
|
|
7038
|
+
return res.json({ ok: true, version: nextVersion, enabled: enable ? true : flow.enabled });
|
|
6998
7039
|
})
|
|
6999
7040
|
);
|
|
7000
7041
|
r.delete(
|
|
@@ -7865,7 +7906,1345 @@ function wrap(logger, handler) {
|
|
|
7865
7906
|
};
|
|
7866
7907
|
}
|
|
7867
7908
|
|
|
7868
|
-
// src/server/api/
|
|
7909
|
+
// src/server/api/agent.ts
|
|
7910
|
+
init_vars();
|
|
7911
|
+
|
|
7912
|
+
// src/server/runner/arm.ts
|
|
7913
|
+
var FlowOperationError = class extends Error {
|
|
7914
|
+
constructor(code, message, status = 400) {
|
|
7915
|
+
super(message);
|
|
7916
|
+
this.code = code;
|
|
7917
|
+
this.status = status;
|
|
7918
|
+
this.name = "FlowOperationError";
|
|
7919
|
+
}
|
|
7920
|
+
code;
|
|
7921
|
+
status;
|
|
7922
|
+
};
|
|
7923
|
+
async function stampWatermarkIfNull(collections, flow, now = /* @__PURE__ */ new Date()) {
|
|
7924
|
+
if (flow.lastTriggerScanAt) return flow.lastTriggerScanAt;
|
|
7925
|
+
await collections.flows.updateOne(
|
|
7926
|
+
{ _id: flow._id, lastTriggerScanAt: null },
|
|
7927
|
+
{ $set: { lastTriggerScanAt: now, updatedAt: now } }
|
|
7928
|
+
);
|
|
7929
|
+
return now;
|
|
7930
|
+
}
|
|
7931
|
+
async function armFlow(mailer, slug, opts) {
|
|
7932
|
+
const c = mailer.collections;
|
|
7933
|
+
const flow = await c.flows.findOne({ slug });
|
|
7934
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
7935
|
+
if (!Array.isArray(flow.steps) || flow.steps.length === 0) {
|
|
7936
|
+
throw new FlowOperationError(
|
|
7937
|
+
"no_live_steps",
|
|
7938
|
+
`flow "${slug}" (v${flow.version}) has no published steps \u2014 publish it first; an empty flow completes every run instantly`,
|
|
7939
|
+
409
|
|
7940
|
+
);
|
|
7941
|
+
}
|
|
7942
|
+
const eventName = flow.trigger?.eventName ?? null;
|
|
7943
|
+
const now = /* @__PURE__ */ new Date();
|
|
7944
|
+
const watermark = opts.since ?? now;
|
|
7945
|
+
if (flow.enabled) {
|
|
7946
|
+
return {
|
|
7947
|
+
slug,
|
|
7948
|
+
version: flow.version,
|
|
7949
|
+
armed: false,
|
|
7950
|
+
alreadyEnabled: true,
|
|
7951
|
+
watermark: flow.lastTriggerScanAt ?? null,
|
|
7952
|
+
eventName,
|
|
7953
|
+
skippedEvents: 0,
|
|
7954
|
+
pendingEvents: 0
|
|
7955
|
+
};
|
|
7956
|
+
}
|
|
7957
|
+
const previous = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
7958
|
+
const overlapFrom = new Date(watermark.getTime() - SCAN_OVERLAP_MS);
|
|
7959
|
+
const [skippedEvents, pendingEvents] = eventName ? await Promise.all([
|
|
7960
|
+
c.events.countDocuments({ name: eventName, createdAt: { $gt: previous, $lte: overlapFrom } }),
|
|
7961
|
+
c.events.countDocuments({ name: eventName, createdAt: { $gt: overlapFrom } })
|
|
7962
|
+
]) : [0, 0];
|
|
7963
|
+
const res = await c.flows.updateOne(
|
|
7964
|
+
{ _id: flow._id, enabled: false },
|
|
7965
|
+
{ $set: { enabled: true, lastTriggerScanAt: watermark, updatedAt: now } }
|
|
7966
|
+
);
|
|
7967
|
+
if (res.modifiedCount === 0) {
|
|
7968
|
+
const again = await c.flows.findOne({ _id: flow._id });
|
|
7969
|
+
return {
|
|
7970
|
+
slug,
|
|
7971
|
+
version: flow.version,
|
|
7972
|
+
armed: false,
|
|
7973
|
+
alreadyEnabled: !!again?.enabled,
|
|
7974
|
+
watermark: again?.lastTriggerScanAt ?? null,
|
|
7975
|
+
eventName,
|
|
7976
|
+
skippedEvents: 0,
|
|
7977
|
+
pendingEvents: 0
|
|
7978
|
+
};
|
|
7979
|
+
}
|
|
7980
|
+
await mailer.audit({
|
|
7981
|
+
actor: opts.actor,
|
|
7982
|
+
action: "flow.arm",
|
|
7983
|
+
resource: { collection: "mailer_flows", id: flow._id, slug },
|
|
7984
|
+
diffSummary: `Enabled v${flow.version} with lastTriggerScanAt=${watermark.toISOString()} (skipped ${skippedEvents} earlier ${eventName ?? "trigger"} event(s), ${pendingEvents} pending)`
|
|
7985
|
+
});
|
|
7986
|
+
return {
|
|
7987
|
+
slug,
|
|
7988
|
+
version: flow.version,
|
|
7989
|
+
armed: true,
|
|
7990
|
+
alreadyEnabled: false,
|
|
7991
|
+
watermark,
|
|
7992
|
+
eventName,
|
|
7993
|
+
skippedEvents,
|
|
7994
|
+
pendingEvents
|
|
7995
|
+
};
|
|
7996
|
+
}
|
|
7997
|
+
async function disarmFlow(mailer, slug, actor) {
|
|
7998
|
+
const c = mailer.collections;
|
|
7999
|
+
const flow = await c.flows.findOne({ slug });
|
|
8000
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
8001
|
+
if (!flow.enabled) return { slug, disarmed: false };
|
|
8002
|
+
await c.flows.updateOne({ _id: flow._id }, { $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } });
|
|
8003
|
+
await mailer.audit({
|
|
8004
|
+
actor,
|
|
8005
|
+
action: "flow.pause",
|
|
8006
|
+
resource: { collection: "mailer_flows", id: flow._id, slug },
|
|
8007
|
+
diffSummary: "Disarmed (enabled: false); in-flight runs continue"
|
|
8008
|
+
});
|
|
8009
|
+
return { slug, disarmed: true };
|
|
8010
|
+
}
|
|
8011
|
+
function isCanaryGate(step) {
|
|
8012
|
+
const s = step;
|
|
8013
|
+
return !!s && s.type === "condition" && s.canaryGate === true;
|
|
8014
|
+
}
|
|
8015
|
+
function gateStep(tag) {
|
|
8016
|
+
return { type: "condition", test: { hasTag: tag }, ifFalse: "exit", canaryGate: true };
|
|
8017
|
+
}
|
|
8018
|
+
async function publishVersion(mailer, flow, steps, actor, summary) {
|
|
8019
|
+
const c = mailer.collections;
|
|
8020
|
+
const nextVersion = (flow.version ?? 0) + 1;
|
|
8021
|
+
const now = /* @__PURE__ */ new Date();
|
|
8022
|
+
await c.flowVersions.insertOne({
|
|
8023
|
+
flowId: flow._id,
|
|
8024
|
+
version: nextVersion,
|
|
8025
|
+
steps,
|
|
8026
|
+
trigger: flow.trigger,
|
|
8027
|
+
publishedAt: now,
|
|
8028
|
+
publishedBy: actor
|
|
8029
|
+
});
|
|
8030
|
+
await c.flows.updateOne(
|
|
8031
|
+
{ _id: flow._id },
|
|
8032
|
+
{ $set: { steps, version: nextVersion, draft: null, publishedAt: now, publishedBy: actor, updatedAt: now } }
|
|
8033
|
+
);
|
|
8034
|
+
await mailer.audit({
|
|
8035
|
+
actor,
|
|
8036
|
+
action: "flow.publish",
|
|
8037
|
+
resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
|
|
8038
|
+
diffSummary: `Published v${nextVersion}: ${summary}`
|
|
8039
|
+
});
|
|
8040
|
+
return { version: nextVersion };
|
|
8041
|
+
}
|
|
8042
|
+
async function gateFlow(mailer, slug, opts) {
|
|
8043
|
+
const tag = String(opts.tag ?? "").trim();
|
|
8044
|
+
if (!tag) throw new FlowOperationError("tag_required", "a canary tag is required");
|
|
8045
|
+
const flow = await mailer.collections.flows.findOne({ slug });
|
|
8046
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
8047
|
+
const live = Array.isArray(flow.steps) ? flow.steps : [];
|
|
8048
|
+
if (live.length === 0) {
|
|
8049
|
+
throw new FlowOperationError("no_live_steps", `flow "${slug}" has no published steps to gate`, 409);
|
|
8050
|
+
}
|
|
8051
|
+
if (isCanaryGate(live[0])) {
|
|
8052
|
+
throw new FlowOperationError(
|
|
8053
|
+
"already_gated",
|
|
8054
|
+
`flow "${slug}" v${flow.version} is already gated on "${live[0].test.hasTag}" \u2014 ungate first`,
|
|
8055
|
+
409
|
|
8056
|
+
);
|
|
8057
|
+
}
|
|
8058
|
+
await mailer.collections.flowVersions.updateOne(
|
|
8059
|
+
{ flowId: flow._id, version: flow.version },
|
|
8060
|
+
{
|
|
8061
|
+
$setOnInsert: {
|
|
8062
|
+
flowId: flow._id,
|
|
8063
|
+
version: flow.version,
|
|
8064
|
+
steps: live,
|
|
8065
|
+
trigger: flow.trigger,
|
|
8066
|
+
publishedAt: flow.publishedAt ?? flow.updatedAt ?? /* @__PURE__ */ new Date(),
|
|
8067
|
+
publishedBy: flow.publishedBy ?? "unknown"
|
|
8068
|
+
}
|
|
8069
|
+
},
|
|
8070
|
+
{ upsert: true }
|
|
8071
|
+
);
|
|
8072
|
+
const { version } = await publishVersion(
|
|
8073
|
+
mailer,
|
|
8074
|
+
flow,
|
|
8075
|
+
[gateStep(tag), ...live],
|
|
8076
|
+
opts.actor,
|
|
8077
|
+
`canary gate on tag "${tag}"`
|
|
8078
|
+
);
|
|
8079
|
+
return { slug, version, tag, enabled: flow.enabled };
|
|
8080
|
+
}
|
|
8081
|
+
async function ungateFlow(mailer, slug, opts) {
|
|
8082
|
+
const c = mailer.collections;
|
|
8083
|
+
const flow = await c.flows.findOne({ slug });
|
|
8084
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
8085
|
+
const live = Array.isArray(flow.steps) ? flow.steps : [];
|
|
8086
|
+
if (!isCanaryGate(live[0])) {
|
|
8087
|
+
throw new FlowOperationError("not_gated", `flow "${slug}" v${flow.version} is not gated`, 409);
|
|
8088
|
+
}
|
|
8089
|
+
const versions = await c.flowVersions.find({ flowId: flow._id }).sort({ version: -1 }).toArray();
|
|
8090
|
+
const clean = versions.find((v) => Array.isArray(v.steps) && v.steps.length > 0 && !isCanaryGate(v.steps[0]));
|
|
8091
|
+
if (!clean) {
|
|
8092
|
+
throw new FlowOperationError(
|
|
8093
|
+
"no_ungated_version",
|
|
8094
|
+
`flow "${slug}" has no ungated version in mailer_flow_versions to restore`,
|
|
8095
|
+
409
|
|
8096
|
+
);
|
|
8097
|
+
}
|
|
8098
|
+
const { version } = await publishVersion(
|
|
8099
|
+
mailer,
|
|
8100
|
+
flow,
|
|
8101
|
+
clean.steps,
|
|
8102
|
+
opts.actor,
|
|
8103
|
+
`restored steps of v${clean.version} (canary gate removed)`
|
|
8104
|
+
);
|
|
8105
|
+
return { slug, version, restoredFrom: clean.version, enabled: flow.enabled };
|
|
8106
|
+
}
|
|
8107
|
+
var MAX_STEPS = 1e3;
|
|
8108
|
+
async function simulateFlow(flow, contact, ctx, opts = {}) {
|
|
8109
|
+
const enteredAt = opts.at ?? /* @__PURE__ */ new Date();
|
|
8110
|
+
const steps = opts.steps ?? flow.steps ?? [];
|
|
8111
|
+
const eventName = flow.trigger?.eventName ?? "simulated";
|
|
8112
|
+
const eventProperties = opts.eventProperties ?? {};
|
|
8113
|
+
const reasons = [];
|
|
8114
|
+
if (!flow.enabled) reasons.push("flow is disabled (enabled: false)");
|
|
8115
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: contact.externalId });
|
|
8116
|
+
if (!sub) reasons.push("contact has no subscription row \u2014 the trigger scan requires one");
|
|
8117
|
+
else if (sub.status !== "subscribed") reasons.push(`subscription status is "${sub.status}", not "subscribed"`);
|
|
8118
|
+
if (flow.trigger?.once) {
|
|
8119
|
+
const existing = await ctx.collections.flowRuns.findOne(
|
|
8120
|
+
{ externalId: contact.externalId, flowId: flow._id },
|
|
8121
|
+
{ projection: { _id: 1, status: 1 } }
|
|
8122
|
+
);
|
|
8123
|
+
if (existing) reasons.push(`trigger.once is true and the contact already has a run (${existing.status})`);
|
|
8124
|
+
}
|
|
8125
|
+
if (steps.length === 0) reasons.push("flow has no live steps \u2014 a run would complete immediately");
|
|
8126
|
+
const run = {
|
|
8127
|
+
_id: new mongodb.ObjectId(),
|
|
8128
|
+
externalId: contact.externalId,
|
|
8129
|
+
flowId: flow._id,
|
|
8130
|
+
flowSlug: flow.slug,
|
|
8131
|
+
flowVersion: flow.version,
|
|
8132
|
+
emailAtEntry: contact.email,
|
|
8133
|
+
triggerEvent: { name: eventName, properties: eventProperties, occurredAt: enteredAt },
|
|
8134
|
+
triggerDedupeKey: null,
|
|
8135
|
+
enteredAt,
|
|
8136
|
+
status: "active",
|
|
8137
|
+
currentStepIndex: 0,
|
|
8138
|
+
currentBranchPath: [],
|
|
8139
|
+
nextActionAt: enteredAt,
|
|
8140
|
+
attemptsForCurrentStep: 0,
|
|
8141
|
+
history: [],
|
|
8142
|
+
exitedAt: null,
|
|
8143
|
+
exitReason: null,
|
|
8144
|
+
createdAt: enteredAt,
|
|
8145
|
+
updatedAt: enteredAt
|
|
8146
|
+
};
|
|
8147
|
+
const path3 = [];
|
|
8148
|
+
const sends = [];
|
|
8149
|
+
let list = steps;
|
|
8150
|
+
let index = 0;
|
|
8151
|
+
let branchPath = [];
|
|
8152
|
+
let t = enteredAt;
|
|
8153
|
+
let terminal = null;
|
|
8154
|
+
const record = (type, outcome, detail) => {
|
|
8155
|
+
path3.push({ at: t, stepIndex: index, branchPath: [...branchPath], type, outcome, ...detail ? { detail } : {} });
|
|
8156
|
+
};
|
|
8157
|
+
const predicateCtx = () => ({
|
|
8158
|
+
contact,
|
|
8159
|
+
run: { ...run, currentStepIndex: index, currentBranchPath: branchPath, nextActionAt: t },
|
|
8160
|
+
collections: ctx.collections,
|
|
8161
|
+
now: t,
|
|
8162
|
+
botFilter: ctx.config.botFilter
|
|
8163
|
+
});
|
|
8164
|
+
for (let guard = 0; guard < MAX_STEPS && !terminal; guard += 1) {
|
|
8165
|
+
const step = list[index];
|
|
8166
|
+
if (!step) {
|
|
8167
|
+
terminal = { kind: "completed", reason: "sequence_complete", at: t };
|
|
8168
|
+
break;
|
|
8169
|
+
}
|
|
8170
|
+
switch (step.type) {
|
|
8171
|
+
case "wait": {
|
|
8172
|
+
const ms = unitToMs2(step.value, step.unit);
|
|
8173
|
+
record("wait", "waited", { value: step.value, unit: step.unit, until: new Date(t.getTime() + ms) });
|
|
8174
|
+
t = new Date(t.getTime() + ms);
|
|
8175
|
+
index += 1;
|
|
8176
|
+
break;
|
|
8177
|
+
}
|
|
8178
|
+
case "condition": {
|
|
8179
|
+
const result = await evaluatePredicate(step.test, predicateCtx());
|
|
8180
|
+
if (result) {
|
|
8181
|
+
record("condition", "passed", { test: step.test, result });
|
|
8182
|
+
index += 1;
|
|
8183
|
+
} else if (step.ifFalse === "continue") {
|
|
8184
|
+
record("condition", "skipped_next", { test: step.test, result });
|
|
8185
|
+
index += 2;
|
|
8186
|
+
} else {
|
|
8187
|
+
record("condition", "exited", { test: step.test, result });
|
|
8188
|
+
terminal = { kind: "exited", reason: "condition_false", at: t };
|
|
8189
|
+
}
|
|
8190
|
+
break;
|
|
8191
|
+
}
|
|
8192
|
+
case "branch": {
|
|
8193
|
+
const result = await evaluatePredicate(step.test, predicateCtx());
|
|
8194
|
+
record("branch", result ? "branch_true" : "branch_false", { test: step.test, result });
|
|
8195
|
+
branchPath = [...branchPath, index, result ? "true" : "false", 0];
|
|
8196
|
+
list = result ? step.ifTrueSteps : step.ifFalseSteps;
|
|
8197
|
+
index = 0;
|
|
8198
|
+
break;
|
|
8199
|
+
}
|
|
8200
|
+
case "send": {
|
|
8201
|
+
let at = t;
|
|
8202
|
+
if (step.delivery) {
|
|
8203
|
+
at = computeDeliveryTime(t, step.delivery, contact.timezone);
|
|
8204
|
+
}
|
|
8205
|
+
if (at.getTime() > t.getTime() + 3e4) {
|
|
8206
|
+
record("send", "send_deferred", { templateSlug: step.templateSlug, delivery: step.delivery, until: at });
|
|
8207
|
+
t = at;
|
|
8208
|
+
}
|
|
8209
|
+
record("send", "send", { templateSlug: step.templateSlug });
|
|
8210
|
+
sends.push({ templateSlug: step.templateSlug, at: t, stepIndex: index, branchPath: [...branchPath] });
|
|
8211
|
+
index += 1;
|
|
8212
|
+
break;
|
|
8213
|
+
}
|
|
8214
|
+
case "tag":
|
|
8215
|
+
record("tag", "tagged", { addTags: step.addTags ?? [], removeTags: step.removeTags ?? [] });
|
|
8216
|
+
index += 1;
|
|
8217
|
+
break;
|
|
8218
|
+
case "fire_event":
|
|
8219
|
+
record("fire_event", "event_fired", { eventName: step.eventName });
|
|
8220
|
+
index += 1;
|
|
8221
|
+
break;
|
|
8222
|
+
case "webhook":
|
|
8223
|
+
record("webhook", "webhook", { url: step.url, method: step.method ?? "POST", note: "not called in simulation" });
|
|
8224
|
+
index += 1;
|
|
8225
|
+
break;
|
|
8226
|
+
case "exit":
|
|
8227
|
+
record("exit", "exited", { reason: step.reason ?? "exit_step" });
|
|
8228
|
+
terminal = { kind: "exited", reason: step.reason ?? "exit_step", at: t };
|
|
8229
|
+
break;
|
|
8230
|
+
}
|
|
8231
|
+
}
|
|
8232
|
+
if (!terminal) terminal = { kind: "truncated", reason: `stopped after ${MAX_STEPS} steps`, at: t };
|
|
8233
|
+
return {
|
|
8234
|
+
flow: { slug: flow.slug, version: flow.version, enabled: flow.enabled },
|
|
8235
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8236
|
+
enteredAt,
|
|
8237
|
+
wouldEnter: { ok: reasons.length === 0, reasons },
|
|
8238
|
+
path: path3,
|
|
8239
|
+
sends,
|
|
8240
|
+
terminal,
|
|
8241
|
+
durationMs: terminal.at.getTime() - enteredAt.getTime()
|
|
8242
|
+
};
|
|
8243
|
+
}
|
|
8244
|
+
function unitToMs2(value, unit) {
|
|
8245
|
+
const m = 6e4;
|
|
8246
|
+
switch (unit) {
|
|
8247
|
+
case "minutes":
|
|
8248
|
+
return value * m;
|
|
8249
|
+
case "hours":
|
|
8250
|
+
return value * 60 * m;
|
|
8251
|
+
case "days":
|
|
8252
|
+
return value * 24 * 60 * m;
|
|
8253
|
+
case "weeks":
|
|
8254
|
+
return value * 7 * 24 * 60 * m;
|
|
8255
|
+
}
|
|
8256
|
+
}
|
|
8257
|
+
|
|
8258
|
+
// src/server/api/agent.ts
|
|
8259
|
+
var VERSION = "0.16.1" ;
|
|
8260
|
+
var MIN_AGENT_TOKEN_LENGTH = 24;
|
|
8261
|
+
function createAgentRouter(mailer, opts) {
|
|
8262
|
+
if (!opts || !Array.isArray(opts.tokens) || opts.tokens.length === 0) {
|
|
8263
|
+
throw new Error("createAgentRouter: at least one bearer token is required \u2014 the agent API is never open");
|
|
8264
|
+
}
|
|
8265
|
+
for (const t of opts.tokens) {
|
|
8266
|
+
if (typeof t?.token !== "string" || t.token.length < MIN_AGENT_TOKEN_LENGTH) {
|
|
8267
|
+
throw new Error(
|
|
8268
|
+
`createAgentRouter: every token must be at least ${MIN_AGENT_TOKEN_LENGTH} characters (got ${t?.token?.length ?? 0})`
|
|
8269
|
+
);
|
|
8270
|
+
}
|
|
8271
|
+
if (typeof t.actor !== "string" || !t.actor.trim()) {
|
|
8272
|
+
throw new Error('createAgentRouter: every token needs an actor label, e.g. "agent:claude"');
|
|
8273
|
+
}
|
|
8274
|
+
}
|
|
8275
|
+
const logger = opts.logger ?? consoleRouteLogger;
|
|
8276
|
+
const isTestContact = testContactMatcher(opts.testContacts);
|
|
8277
|
+
const c = mailer.collections;
|
|
8278
|
+
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
8279
|
+
const router = express.Router();
|
|
8280
|
+
router.use(express__default.default.json({ limit: "1mb" }));
|
|
8281
|
+
router.use(bearerAuth(opts.tokens));
|
|
8282
|
+
router.use("/api", createAdminApiRouter(mailer, { mailTesterClient: opts.mailTesterClient }));
|
|
8283
|
+
const actorOf = (req) => String(req.actor);
|
|
8284
|
+
function guardTestContact(res, contact) {
|
|
8285
|
+
if (!isTestContact) {
|
|
8286
|
+
res.status(403).json({
|
|
8287
|
+
error: "test_contacts_not_configured",
|
|
8288
|
+
message: "this route only acts on test contacts, and the router was constructed without a testContacts pattern"
|
|
8289
|
+
});
|
|
8290
|
+
return false;
|
|
8291
|
+
}
|
|
8292
|
+
if (!isTestContact(contact.email)) {
|
|
8293
|
+
res.status(403).json({
|
|
8294
|
+
error: "not_a_test_contact",
|
|
8295
|
+
message: `${contact.email} does not match the testContacts pattern`,
|
|
8296
|
+
externalId: contact.externalId
|
|
8297
|
+
});
|
|
8298
|
+
return false;
|
|
8299
|
+
}
|
|
8300
|
+
return true;
|
|
8301
|
+
}
|
|
8302
|
+
async function loadContact(res, externalId) {
|
|
8303
|
+
const contact = await mailer.adapter.getById(externalId);
|
|
8304
|
+
if (!contact) res.status(404).json({ error: "contact_not_found", externalId });
|
|
8305
|
+
return contact;
|
|
8306
|
+
}
|
|
8307
|
+
async function loadTemplate(res, slug) {
|
|
8308
|
+
const tpl = await c.templates.findOne({ slug });
|
|
8309
|
+
if (!tpl) res.status(404).json({ error: "template_not_found", slug });
|
|
8310
|
+
return tpl;
|
|
8311
|
+
}
|
|
8312
|
+
router.get("/", (req, res) => {
|
|
8313
|
+
res.json({
|
|
8314
|
+
service: "mailery-agent",
|
|
8315
|
+
version: VERSION,
|
|
8316
|
+
actor: actorOf(req),
|
|
8317
|
+
testContactsConfigured: !!isTestContact,
|
|
8318
|
+
docs: "https://jeffjassky.github.io/mailery/reference/agent-api",
|
|
8319
|
+
endpoints: ENDPOINTS
|
|
8320
|
+
});
|
|
8321
|
+
});
|
|
8322
|
+
router.post(
|
|
8323
|
+
"/templates/:slug/verify",
|
|
8324
|
+
wrap2(async (req, res) => {
|
|
8325
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8326
|
+
if (!tpl) return;
|
|
8327
|
+
const contact = await contactForRender(req, res);
|
|
8328
|
+
if (!contact) return;
|
|
8329
|
+
const report = await verifyTemplate(mailer, tpl, contact, {
|
|
8330
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8331
|
+
vars: objectOrUndefined(req.body?.vars),
|
|
8332
|
+
includeRendered: req.body?.includeRendered === true,
|
|
8333
|
+
varsSchema
|
|
8334
|
+
});
|
|
8335
|
+
res.status(200).json(report);
|
|
8336
|
+
})
|
|
8337
|
+
);
|
|
8338
|
+
router.post(
|
|
8339
|
+
"/templates/verify-all",
|
|
8340
|
+
wrap2(async (req, res) => {
|
|
8341
|
+
const slugs = Array.isArray(req.body?.slugs) ? req.body.slugs.map(String) : null;
|
|
8342
|
+
const contactIds = Array.isArray(req.body?.contactIds) ? req.body.contactIds.map(String) : [];
|
|
8343
|
+
if (contactIds.length === 0) {
|
|
8344
|
+
return res.status(400).json({ error: "validation_failed", message: "contactIds (non-empty array) is required" });
|
|
8345
|
+
}
|
|
8346
|
+
const templates = await c.templates.find(slugs ? { slug: { $in: slugs } } : {}).sort({ slug: 1 }).toArray();
|
|
8347
|
+
const contacts = [];
|
|
8348
|
+
for (const id of contactIds) {
|
|
8349
|
+
const found = await mailer.adapter.getById(id);
|
|
8350
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", externalId: id });
|
|
8351
|
+
contacts.push(found);
|
|
8352
|
+
}
|
|
8353
|
+
const results = [];
|
|
8354
|
+
for (const tpl of templates) {
|
|
8355
|
+
for (const contact of contacts) {
|
|
8356
|
+
const report = await verifyTemplate(mailer, tpl, contact, {
|
|
8357
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8358
|
+
includeRendered: false,
|
|
8359
|
+
varsSchema
|
|
8360
|
+
});
|
|
8361
|
+
results.push({
|
|
8362
|
+
slug: tpl.slug,
|
|
8363
|
+
contactId: contact.externalId,
|
|
8364
|
+
ok: report.ok,
|
|
8365
|
+
failed: report.checks.filter((k) => k.status === "fail").map((k) => k.id),
|
|
8366
|
+
warned: report.checks.filter((k) => k.status === "warn").map((k) => k.id)
|
|
8367
|
+
});
|
|
8368
|
+
}
|
|
8369
|
+
}
|
|
8370
|
+
const failing = results.filter((r) => !r.ok);
|
|
8371
|
+
res.json({
|
|
8372
|
+
ok: failing.length === 0,
|
|
8373
|
+
templates: templates.length,
|
|
8374
|
+
contacts: contacts.length,
|
|
8375
|
+
verified: results.length,
|
|
8376
|
+
failing: failing.length,
|
|
8377
|
+
results
|
|
8378
|
+
});
|
|
8379
|
+
})
|
|
8380
|
+
);
|
|
8381
|
+
router.post(
|
|
8382
|
+
"/templates/:slug/render",
|
|
8383
|
+
wrap2(async (req, res) => {
|
|
8384
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8385
|
+
if (!tpl) return;
|
|
8386
|
+
const contact = await contactForRender(req, res);
|
|
8387
|
+
if (!contact) return;
|
|
8388
|
+
const out = await renderForContact(mailer, tpl, contact, {
|
|
8389
|
+
reason: "preview",
|
|
8390
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8391
|
+
vars: objectOrUndefined(req.body?.vars)
|
|
8392
|
+
});
|
|
8393
|
+
res.json({
|
|
8394
|
+
template: { slug: tpl.slug, kind: tpl.kind },
|
|
8395
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8396
|
+
subject: out.rendered.subject,
|
|
8397
|
+
preheader: out.rendered.preheader,
|
|
8398
|
+
fromName: out.rendered.fromName,
|
|
8399
|
+
fromEmail: out.rendered.fromEmail,
|
|
8400
|
+
replyTo: out.rendered.replyTo,
|
|
8401
|
+
html: out.rendered.html,
|
|
8402
|
+
plainText: out.rendered.plainText,
|
|
8403
|
+
resolvedVars: out.resolved,
|
|
8404
|
+
unsubscribeUrl: out.unsubscribeUrl
|
|
8405
|
+
});
|
|
8406
|
+
})
|
|
8407
|
+
);
|
|
8408
|
+
router.post(
|
|
8409
|
+
"/templates/:slug/send",
|
|
8410
|
+
wrap2(async (req, res) => {
|
|
8411
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8412
|
+
if (!tpl) return;
|
|
8413
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8414
|
+
if (!contactId) return res.status(400).json({ error: "validation_failed", message: "contactId is required" });
|
|
8415
|
+
const contact = await loadContact(res, contactId);
|
|
8416
|
+
if (!contact) return;
|
|
8417
|
+
if (!guardTestContact(res, contact)) return;
|
|
8418
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
8419
|
+
return res.status(409).json({ error: "not_published", message: "template has no published body" });
|
|
8420
|
+
}
|
|
8421
|
+
const dedupeKey = typeof req.body?.dedupeKey === "string" && req.body.dedupeKey ? String(req.body.dedupeKey) : `agent:${crypto2__default.default.randomUUID()}`;
|
|
8422
|
+
const { sendId } = await mailer.sendOneOff({
|
|
8423
|
+
templateSlug: tpl.slug,
|
|
8424
|
+
externalId: contact.externalId,
|
|
8425
|
+
dedupeKey,
|
|
8426
|
+
vars: objectOrUndefined(req.body?.vars)
|
|
8427
|
+
});
|
|
8428
|
+
const dispatchNow = req.body?.dispatch !== "queue";
|
|
8429
|
+
if (dispatchNow) {
|
|
8430
|
+
await dispatchSend(new mongodb.ObjectId(sendId), mailer.getRunnerContext());
|
|
8431
|
+
}
|
|
8432
|
+
const send = await c.sends.findOne({ _id: new mongodb.ObjectId(sendId) });
|
|
8433
|
+
await mailer.audit({
|
|
8434
|
+
actor: actorOf(req),
|
|
8435
|
+
action: "agent.send",
|
|
8436
|
+
resource: { collection: "mailer_sends", id: new mongodb.ObjectId(sendId), slug: tpl.slug },
|
|
8437
|
+
diffSummary: `to=${contact.email} dispatch=${dispatchNow ? "now" : "queue"} dedupeKey=${dedupeKey}`
|
|
8438
|
+
});
|
|
8439
|
+
res.status(201).json({ sendId, dedupeKey, dispatched: dispatchNow, send: send ? sendSummary(send) : null });
|
|
8440
|
+
})
|
|
8441
|
+
);
|
|
8442
|
+
router.get(
|
|
8443
|
+
"/sends/:id/wait",
|
|
8444
|
+
wrap2(async (req, res) => {
|
|
8445
|
+
const id = String(req.params.id);
|
|
8446
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8447
|
+
const target = String(req.query.status ?? "delivered");
|
|
8448
|
+
if (!WAIT_TARGETS.has(target)) {
|
|
8449
|
+
return res.status(400).json({ error: "validation_failed", message: `status must be one of ${[...WAIT_TARGETS].join(", ")}` });
|
|
8450
|
+
}
|
|
8451
|
+
const timeoutMs = Math.max(0, Math.min(Number(req.query.timeoutMs ?? 3e4) || 0, 55e3));
|
|
8452
|
+
const started = Date.now();
|
|
8453
|
+
let send = null;
|
|
8454
|
+
let reached = false;
|
|
8455
|
+
for (; ; ) {
|
|
8456
|
+
send = await c.sends.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8457
|
+
if (!send) return res.status(404).json({ error: "not_found" });
|
|
8458
|
+
reached = waitTargetReached(send, target);
|
|
8459
|
+
if (reached || Date.now() - started >= timeoutMs) break;
|
|
8460
|
+
await sleep2(1e3);
|
|
8461
|
+
}
|
|
8462
|
+
const webhookEvents = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).sort({ receivedAt: 1 }).limit(100).toArray() : [];
|
|
8463
|
+
res.json({ reached, target, waitedMs: Date.now() - started, send: sendSummary(send), webhookEvents });
|
|
8464
|
+
})
|
|
8465
|
+
);
|
|
8466
|
+
router.post(
|
|
8467
|
+
"/sends/:id/dispatch",
|
|
8468
|
+
wrap2(async (req, res) => {
|
|
8469
|
+
const id = String(req.params.id);
|
|
8470
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8471
|
+
const send = await c.sends.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8472
|
+
if (!send) return res.status(404).json({ error: "not_found" });
|
|
8473
|
+
const contact = await loadContact(res, send.externalId);
|
|
8474
|
+
if (!contact) return;
|
|
8475
|
+
if (!guardTestContact(res, contact)) return;
|
|
8476
|
+
await dispatchSend(send._id, mailer.getRunnerContext());
|
|
8477
|
+
const after = await c.sends.findOne({ _id: send._id });
|
|
8478
|
+
res.json({ send: after ? sendSummary(after) : null });
|
|
8479
|
+
})
|
|
8480
|
+
);
|
|
8481
|
+
router.post(
|
|
8482
|
+
"/flows/:slug/simulate",
|
|
8483
|
+
wrap2(async (req, res) => {
|
|
8484
|
+
const flow = await c.flows.findOne({ slug: String(req.params.slug) });
|
|
8485
|
+
if (!flow) return res.status(404).json({ error: "flow_not_found", slug: String(req.params.slug) });
|
|
8486
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8487
|
+
if (!contactId) return res.status(400).json({ error: "validation_failed", message: "contactId is required" });
|
|
8488
|
+
const contact = await loadContact(res, contactId);
|
|
8489
|
+
if (!contact) return;
|
|
8490
|
+
let at;
|
|
8491
|
+
if (req.body?.at) {
|
|
8492
|
+
at = new Date(String(req.body.at));
|
|
8493
|
+
if (Number.isNaN(at.getTime())) return res.status(400).json({ error: "validation_failed", message: "at must be an ISO date" });
|
|
8494
|
+
}
|
|
8495
|
+
let steps = void 0;
|
|
8496
|
+
if (req.body?.version !== void 0) {
|
|
8497
|
+
const v = Number(req.body.version);
|
|
8498
|
+
if (v !== flow.version) {
|
|
8499
|
+
const snap = await c.flowVersions.findOne({ flowId: flow._id, version: v });
|
|
8500
|
+
if (!snap) return res.status(404).json({ error: "version_not_found", version: v });
|
|
8501
|
+
steps = snap.steps;
|
|
8502
|
+
}
|
|
8503
|
+
}
|
|
8504
|
+
const result = await simulateFlow(flow, contact, mailer.getRunnerContext(), {
|
|
8505
|
+
at,
|
|
8506
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8507
|
+
steps
|
|
8508
|
+
});
|
|
8509
|
+
res.json(result);
|
|
8510
|
+
})
|
|
8511
|
+
);
|
|
8512
|
+
router.post(
|
|
8513
|
+
"/flows/:slug/arm",
|
|
8514
|
+
wrap2(async (req, res) => {
|
|
8515
|
+
if (req.body?.confirm !== true) {
|
|
8516
|
+
return res.status(400).json({
|
|
8517
|
+
error: "confirm_required",
|
|
8518
|
+
message: 'arming enables a flow for every future matching event; pass {"confirm": true}'
|
|
8519
|
+
});
|
|
8520
|
+
}
|
|
8521
|
+
let since;
|
|
8522
|
+
if (req.body?.since) {
|
|
8523
|
+
since = new Date(String(req.body.since));
|
|
8524
|
+
if (Number.isNaN(since.getTime())) return res.status(400).json({ error: "validation_failed", message: "since must be an ISO date" });
|
|
8525
|
+
}
|
|
8526
|
+
const result = await armFlow(mailer, String(req.params.slug), { actor: actorOf(req), since });
|
|
8527
|
+
res.json(result);
|
|
8528
|
+
})
|
|
8529
|
+
);
|
|
8530
|
+
router.post(
|
|
8531
|
+
"/flows/:slug/disarm",
|
|
8532
|
+
wrap2(async (req, res) => {
|
|
8533
|
+
res.json(await disarmFlow(mailer, String(req.params.slug), actorOf(req)));
|
|
8534
|
+
})
|
|
8535
|
+
);
|
|
8536
|
+
router.post(
|
|
8537
|
+
"/flows/:slug/gate",
|
|
8538
|
+
wrap2(async (req, res) => {
|
|
8539
|
+
const tag = typeof req.body?.tag === "string" ? req.body.tag : "";
|
|
8540
|
+
res.json(await gateFlow(mailer, String(req.params.slug), { tag, actor: actorOf(req) }));
|
|
8541
|
+
})
|
|
8542
|
+
);
|
|
8543
|
+
router.post(
|
|
8544
|
+
"/flows/:slug/ungate",
|
|
8545
|
+
wrap2(async (req, res) => {
|
|
8546
|
+
res.json(await ungateFlow(mailer, String(req.params.slug), { actor: actorOf(req) }));
|
|
8547
|
+
})
|
|
8548
|
+
);
|
|
8549
|
+
router.get(
|
|
8550
|
+
"/runs",
|
|
8551
|
+
wrap2(async (req, res) => {
|
|
8552
|
+
const filter = {};
|
|
8553
|
+
if (typeof req.query.externalId === "string") filter.externalId = req.query.externalId;
|
|
8554
|
+
if (typeof req.query.flowSlug === "string") filter.flowSlug = req.query.flowSlug;
|
|
8555
|
+
if (typeof req.query.status === "string") filter.status = req.query.status;
|
|
8556
|
+
const limit = Math.min(Math.max(Number(req.query.limit ?? 50) || 50, 1), 200);
|
|
8557
|
+
const runs = await c.flowRuns.find(filter).sort({ enteredAt: -1 }).limit(limit).toArray();
|
|
8558
|
+
res.json(runs.map(runSummary));
|
|
8559
|
+
})
|
|
8560
|
+
);
|
|
8561
|
+
router.get(
|
|
8562
|
+
"/runs/:id",
|
|
8563
|
+
wrap2(async (req, res) => {
|
|
8564
|
+
const id = String(req.params.id);
|
|
8565
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8566
|
+
const run = await c.flowRuns.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8567
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8568
|
+
const sends = await c.sends.find({ flowRunId: run._id }).sort({ queuedAt: 1 }).toArray();
|
|
8569
|
+
res.json({ run, sends: sends.map(sendSummary) });
|
|
8570
|
+
})
|
|
8571
|
+
);
|
|
8572
|
+
router.post(
|
|
8573
|
+
"/runs/:id/advance",
|
|
8574
|
+
wrap2(async (req, res) => {
|
|
8575
|
+
const id = String(req.params.id);
|
|
8576
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8577
|
+
const run = await c.flowRuns.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8578
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8579
|
+
const contact = await loadContact(res, run.externalId);
|
|
8580
|
+
if (!contact) return;
|
|
8581
|
+
if (!guardTestContact(res, contact)) return;
|
|
8582
|
+
if (run.status !== "active") {
|
|
8583
|
+
return res.status(409).json({ error: "run_not_active", status: run.status, exitReason: run.exitReason });
|
|
8584
|
+
}
|
|
8585
|
+
const steps = Math.min(Math.max(Number(req.body?.steps ?? 1) || 1, 1), 50);
|
|
8586
|
+
const dispatch = req.body?.dispatch !== false;
|
|
8587
|
+
const ctx = mailer.getRunnerContext();
|
|
8588
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
8589
|
+
const historyBefore = run.history.length;
|
|
8590
|
+
const actor = actorOf(req);
|
|
8591
|
+
for (let i = 0; i < steps; i += 1) {
|
|
8592
|
+
const advanced = await advanceOnce(run._id, ctx, actor, c);
|
|
8593
|
+
if (!advanced) break;
|
|
8594
|
+
}
|
|
8595
|
+
const after = await c.flowRuns.findOne({ _id: run._id });
|
|
8596
|
+
const newSends = await c.sends.find({ flowRunId: run._id, queuedAt: { $gte: startedAt } }).toArray();
|
|
8597
|
+
if (dispatch) {
|
|
8598
|
+
for (const s of newSends) {
|
|
8599
|
+
if (s.status === "queued") await dispatchSend(s._id, ctx);
|
|
8600
|
+
}
|
|
8601
|
+
}
|
|
8602
|
+
const sends = await c.sends.find({ flowRunId: run._id, queuedAt: { $gte: startedAt } }).toArray();
|
|
8603
|
+
await mailer.audit({
|
|
8604
|
+
actor,
|
|
8605
|
+
action: "agent.run.advance",
|
|
8606
|
+
resource: { collection: "mailer_flow_runs", id: run._id, slug: run.flowSlug },
|
|
8607
|
+
diffSummary: `advanced ${steps} step(s) for ${contact.email}; ${sends.length} send(s) created`
|
|
8608
|
+
});
|
|
8609
|
+
res.json({
|
|
8610
|
+
run: after ? runSummary(after) : null,
|
|
8611
|
+
historyAdded: after ? after.history.slice(historyBefore) : [],
|
|
8612
|
+
sends: sends.map(sendSummary)
|
|
8613
|
+
});
|
|
8614
|
+
})
|
|
8615
|
+
);
|
|
8616
|
+
router.post(
|
|
8617
|
+
"/runs/:id/cancel",
|
|
8618
|
+
wrap2(async (req, res) => {
|
|
8619
|
+
const id = String(req.params.id);
|
|
8620
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8621
|
+
const run = await c.flowRuns.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8622
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8623
|
+
if (run.status !== "active") return res.status(409).json({ error: "run_not_active", status: run.status });
|
|
8624
|
+
const actor = actorOf(req);
|
|
8625
|
+
await exitFlowRun(run, `aborted_by_host:${actor}`, mailer.getRunnerContext());
|
|
8626
|
+
const cancelled = await c.sends.updateMany(
|
|
8627
|
+
{ flowRunId: run._id, status: "queued" },
|
|
8628
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: aborted_by_host:${actor}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
8629
|
+
);
|
|
8630
|
+
await mailer.audit({
|
|
8631
|
+
actor,
|
|
8632
|
+
action: "agent.run.cancel",
|
|
8633
|
+
resource: { collection: "mailer_flow_runs", id: run._id, slug: run.flowSlug },
|
|
8634
|
+
diffSummary: `cancelled run for ${run.externalId}; ${cancelled.modifiedCount} queued send(s) cancelled`
|
|
8635
|
+
});
|
|
8636
|
+
const after = await c.flowRuns.findOne({ _id: run._id });
|
|
8637
|
+
res.json({ run: after ? runSummary(after) : null, cancelledSends: cancelled.modifiedCount });
|
|
8638
|
+
})
|
|
8639
|
+
);
|
|
8640
|
+
router.post(
|
|
8641
|
+
"/events",
|
|
8642
|
+
wrap2(async (req, res) => {
|
|
8643
|
+
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
|
8644
|
+
const externalId = typeof req.body?.externalId === "string" ? req.body.externalId : "";
|
|
8645
|
+
if (!name || !externalId) {
|
|
8646
|
+
return res.status(400).json({ error: "validation_failed", message: "name and externalId are required" });
|
|
8647
|
+
}
|
|
8648
|
+
const contact = await loadContact(res, externalId);
|
|
8649
|
+
if (!contact) return;
|
|
8650
|
+
if (!guardTestContact(res, contact)) return;
|
|
8651
|
+
const dedupeKey = typeof req.body?.dedupeKey === "string" && req.body.dedupeKey ? req.body.dedupeKey : void 0;
|
|
8652
|
+
try {
|
|
8653
|
+
await mailer.fire(name, externalId, objectOrUndefined(req.body?.properties) ?? {}, dedupeKey);
|
|
8654
|
+
} catch (err) {
|
|
8655
|
+
return res.status(400).json({ error: "fire_failed", message: String(err?.message ?? err) });
|
|
8656
|
+
}
|
|
8657
|
+
const latest = await c.events.findOne({ externalId, name }, { sort: { createdAt: -1 } });
|
|
8658
|
+
await mailer.audit({
|
|
8659
|
+
actor: actorOf(req),
|
|
8660
|
+
action: "agent.event.fire",
|
|
8661
|
+
resource: { collection: "mailer_events", id: latest?._id },
|
|
8662
|
+
diffSummary: `${name} for ${contact.email}${dedupeKey ? ` key=${dedupeKey}` : ""}`
|
|
8663
|
+
});
|
|
8664
|
+
res.status(201).json({ ok: true, event: latest });
|
|
8665
|
+
})
|
|
8666
|
+
);
|
|
8667
|
+
router.get(
|
|
8668
|
+
"/contacts/by-email/:email",
|
|
8669
|
+
wrap2(async (req, res) => {
|
|
8670
|
+
const contact = await mailer.adapter.getByEmail(String(req.params.email).toLowerCase());
|
|
8671
|
+
if (!contact) return res.status(404).json({ error: "contact_not_found", email: req.params.email });
|
|
8672
|
+
res.json(await contactDetail(contact));
|
|
8673
|
+
})
|
|
8674
|
+
);
|
|
8675
|
+
router.get(
|
|
8676
|
+
"/contacts/:externalId",
|
|
8677
|
+
wrap2(async (req, res) => {
|
|
8678
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8679
|
+
if (!contact) return;
|
|
8680
|
+
res.json(await contactDetail(contact));
|
|
8681
|
+
})
|
|
8682
|
+
);
|
|
8683
|
+
router.get(
|
|
8684
|
+
"/contacts/:externalId/unsubscribe-url",
|
|
8685
|
+
wrap2(async (req, res) => {
|
|
8686
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8687
|
+
if (!contact) return;
|
|
8688
|
+
if (!guardTestContact(res, contact)) return;
|
|
8689
|
+
res.json({ contact: { externalId: contact.externalId, email: contact.email }, unsubscribeUrl: unsubscribeUrlFor(mailer, contact.email) });
|
|
8690
|
+
})
|
|
8691
|
+
);
|
|
8692
|
+
router.post(
|
|
8693
|
+
"/contacts/:externalId/subscribe",
|
|
8694
|
+
wrap2(async (req, res) => {
|
|
8695
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8696
|
+
if (!contact) return;
|
|
8697
|
+
if (!guardTestContact(res, contact)) return;
|
|
8698
|
+
await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent" });
|
|
8699
|
+
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8700
|
+
await mailer.audit({
|
|
8701
|
+
actor: actorOf(req),
|
|
8702
|
+
action: "agent.contact.subscribe",
|
|
8703
|
+
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8704
|
+
diffSummary: contact.email
|
|
8705
|
+
});
|
|
8706
|
+
res.json({ subscription: sub });
|
|
8707
|
+
})
|
|
8708
|
+
);
|
|
8709
|
+
router.post(
|
|
8710
|
+
"/contacts/:externalId/unsubscribe",
|
|
8711
|
+
wrap2(async (req, res) => {
|
|
8712
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8713
|
+
if (!contact) return;
|
|
8714
|
+
if (!guardTestContact(res, contact)) return;
|
|
8715
|
+
await mailer.unsubscribe(contact.email, { scope: "marketing", reason: "user_request", source: "agent" });
|
|
8716
|
+
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8717
|
+
await mailer.audit({
|
|
8718
|
+
actor: actorOf(req),
|
|
8719
|
+
action: "agent.contact.unsubscribe",
|
|
8720
|
+
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8721
|
+
diffSummary: contact.email
|
|
8722
|
+
});
|
|
8723
|
+
res.json({ subscription: sub });
|
|
8724
|
+
})
|
|
8725
|
+
);
|
|
8726
|
+
router.post(
|
|
8727
|
+
"/contacts/:externalId/reset",
|
|
8728
|
+
wrap2(async (req, res) => {
|
|
8729
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8730
|
+
if (!contact) return;
|
|
8731
|
+
if (!guardTestContact(res, contact)) return;
|
|
8732
|
+
const b = req.body ?? {};
|
|
8733
|
+
const eventFilter = { externalId: contact.externalId };
|
|
8734
|
+
if (Array.isArray(b.events)) eventFilter.name = { $in: b.events.map(String) };
|
|
8735
|
+
const doRuns = b.runs !== false;
|
|
8736
|
+
const doSends = b.sends !== false;
|
|
8737
|
+
const doEvents = b.events !== false;
|
|
8738
|
+
const doSuppressions = b.suppressions !== false;
|
|
8739
|
+
const doSubscribe = b.subscribe !== false;
|
|
8740
|
+
const removed = { runs: 0, sends: 0, events: 0, suppressions: 0 };
|
|
8741
|
+
if (doRuns) removed.runs = (await c.flowRuns.deleteMany({ externalId: contact.externalId })).deletedCount;
|
|
8742
|
+
if (doSends) removed.sends = (await c.sends.deleteMany({ externalId: contact.externalId })).deletedCount;
|
|
8743
|
+
if (doEvents) removed.events = (await c.events.deleteMany(eventFilter)).deletedCount;
|
|
8744
|
+
if (doSuppressions) removed.suppressions = (await c.suppressions.deleteMany({ email: contact.email })).deletedCount;
|
|
8745
|
+
if (doSubscribe) await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent-reset" });
|
|
8746
|
+
const subscription = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8747
|
+
await mailer.audit({
|
|
8748
|
+
actor: actorOf(req),
|
|
8749
|
+
action: "agent.contact.reset",
|
|
8750
|
+
resource: { collection: "mailer_subscriptions", id: subscription?._id },
|
|
8751
|
+
diffSummary: `${contact.email}: removed ${removed.runs} run(s), ${removed.sends} send(s), ${removed.events} event(s), ${removed.suppressions} suppression(s)${doSubscribe ? "; resubscribed" : ""}`
|
|
8752
|
+
});
|
|
8753
|
+
res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
|
|
8754
|
+
})
|
|
8755
|
+
);
|
|
8756
|
+
router.post(
|
|
8757
|
+
"/tick",
|
|
8758
|
+
wrap2(async (_req, res) => {
|
|
8759
|
+
const started = Date.now();
|
|
8760
|
+
await runTick(mailer.getRunnerContext());
|
|
8761
|
+
res.json({ ok: true, ms: Date.now() - started });
|
|
8762
|
+
})
|
|
8763
|
+
);
|
|
8764
|
+
router.get(
|
|
8765
|
+
"/webhooks/status",
|
|
8766
|
+
wrap2(async (_req, res) => {
|
|
8767
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
8768
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
|
|
8769
|
+
const [last, byType24h, total7d, unprocessed] = await Promise.all([
|
|
8770
|
+
c.webhookEvents.findOne({}, { sort: { receivedAt: -1 }, projection: { receivedAt: 1, provider: 1, normalizedType: 1 } }),
|
|
8771
|
+
c.webhookEvents.aggregate([{ $match: { receivedAt: { $gte: dayAgo } } }, { $group: { _id: "$normalizedType", n: { $sum: 1 } } }]).toArray(),
|
|
8772
|
+
c.webhookEvents.countDocuments({ receivedAt: { $gte: weekAgo } }),
|
|
8773
|
+
c.webhookEvents.countDocuments({ processed: false })
|
|
8774
|
+
]);
|
|
8775
|
+
res.json({
|
|
8776
|
+
providers: Object.keys(mailer.providers),
|
|
8777
|
+
lastReceivedAt: last?.receivedAt ?? null,
|
|
8778
|
+
lastProvider: last?.provider ?? null,
|
|
8779
|
+
last24h: Object.fromEntries(byType24h.map((r) => [r._id, r.n])),
|
|
8780
|
+
last7d: total7d,
|
|
8781
|
+
unprocessed,
|
|
8782
|
+
ingestPath: `${mailer.config.publicUrl}/m/webhooks/<provider>`
|
|
8783
|
+
});
|
|
8784
|
+
})
|
|
8785
|
+
);
|
|
8786
|
+
router.get(
|
|
8787
|
+
"/status",
|
|
8788
|
+
wrap2(async (_req, res) => {
|
|
8789
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
8790
|
+
const [flows, templates, subscribed, suppressions, activeRuns, sends24h, webhooks24h, lastWebhook, healthDocs, setup] = await Promise.all([
|
|
8791
|
+
c.flows.find({}, { projection: { slug: 1, enabled: 1, version: 1, lastTriggerScanAt: 1, steps: 1, trigger: 1 } }).sort({ slug: 1 }).toArray(),
|
|
8792
|
+
c.templates.find({}, { projection: { slug: 1, kind: 1, publishedAt: 1, "body.html": 1, fromEmail: 1 } }).sort({ slug: 1 }).toArray(),
|
|
8793
|
+
c.subscriptions.countDocuments({ status: "subscribed" }),
|
|
8794
|
+
c.suppressions.estimatedDocumentCount(),
|
|
8795
|
+
c.flowRuns.countDocuments({ status: "active" }),
|
|
8796
|
+
c.sends.aggregate([{ $match: { queuedAt: { $gte: dayAgo } } }, { $group: { _id: "$status", n: { $sum: 1 } } }]).toArray(),
|
|
8797
|
+
c.webhookEvents.countDocuments({ receivedAt: { $gte: dayAgo } }),
|
|
8798
|
+
c.webhookEvents.findOne({}, { sort: { receivedAt: -1 }, projection: { receivedAt: 1 } }),
|
|
8799
|
+
c.health.find({}).limit(500).toArray(),
|
|
8800
|
+
runSetupChecks(mailer)
|
|
8801
|
+
]);
|
|
8802
|
+
const activeByFlow = await c.flowRuns.aggregate([{ $match: { status: "active" } }, { $group: { _id: "$flowSlug", n: { $sum: 1 } } }]).toArray();
|
|
8803
|
+
const activeMap = new Map(activeByFlow.map((r) => [r._id, r.n]));
|
|
8804
|
+
res.json({
|
|
8805
|
+
version: VERSION,
|
|
8806
|
+
now: /* @__PURE__ */ new Date(),
|
|
8807
|
+
testContactsConfigured: !!isTestContact,
|
|
8808
|
+
setup,
|
|
8809
|
+
health: { status: healthDocs.length ? effectiveOverallStatus(healthDocs) : null, aggregate: healthDocs.find((d) => d._id === HEALTH_AGG_ID) ?? null },
|
|
8810
|
+
flows: flows.map((f) => ({
|
|
8811
|
+
slug: f.slug,
|
|
8812
|
+
enabled: f.enabled,
|
|
8813
|
+
version: f.version,
|
|
8814
|
+
lastTriggerScanAt: f.lastTriggerScanAt ?? null,
|
|
8815
|
+
trigger: f.trigger,
|
|
8816
|
+
liveSteps: Array.isArray(f.steps) ? f.steps.length : 0,
|
|
8817
|
+
gated: Array.isArray(f.steps) && isCanaryGate(f.steps[0]) ? f.steps[0].test.hasTag : null,
|
|
8818
|
+
activeRuns: activeMap.get(f.slug) ?? 0
|
|
8819
|
+
})),
|
|
8820
|
+
templates: templates.map((t) => ({
|
|
8821
|
+
slug: t.slug,
|
|
8822
|
+
kind: t.kind,
|
|
8823
|
+
fromEmail: t.fromEmail,
|
|
8824
|
+
published: !!(t.body && t.body.html),
|
|
8825
|
+
publishedAt: t.publishedAt ?? null
|
|
8826
|
+
})),
|
|
8827
|
+
counts: {
|
|
8828
|
+
subscribed,
|
|
8829
|
+
suppressions,
|
|
8830
|
+
activeRuns,
|
|
8831
|
+
sendsLast24h: Object.fromEntries(sends24h.map((r) => [r._id, r.n])),
|
|
8832
|
+
webhookEventsLast24h: webhooks24h,
|
|
8833
|
+
lastWebhookAt: lastWebhook?.receivedAt ?? null
|
|
8834
|
+
}
|
|
8835
|
+
});
|
|
8836
|
+
})
|
|
8837
|
+
);
|
|
8838
|
+
router.use((req, res) => {
|
|
8839
|
+
res.status(404).json({ error: "not_found", message: `no agent route ${req.method} ${req.path}; GET / lists them` });
|
|
8840
|
+
});
|
|
8841
|
+
router.use((err, req, res, _next) => {
|
|
8842
|
+
if (err instanceof FlowOperationError) {
|
|
8843
|
+
return res.status(err.status).json({ error: err.code, message: err.message });
|
|
8844
|
+
}
|
|
8845
|
+
if (err?.name === "ZodError") {
|
|
8846
|
+
return res.status(400).json({ error: "validation_failed", issues: err.issues });
|
|
8847
|
+
}
|
|
8848
|
+
logger.error?.({ err: String(err?.message ?? err), path: req.path, method: req.method }, "mailery agent: request failed");
|
|
8849
|
+
if (res.headersSent) return;
|
|
8850
|
+
res.status(500).json({ error: "internal", message: String(err?.message ?? err) });
|
|
8851
|
+
});
|
|
8852
|
+
return router;
|
|
8853
|
+
async function contactForRender(req, res) {
|
|
8854
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8855
|
+
if (contactId) return loadContact(res, contactId);
|
|
8856
|
+
const sample = req.body?.sampleContact;
|
|
8857
|
+
if (sample && typeof sample === "object" && typeof sample.email === "string") {
|
|
8858
|
+
return {
|
|
8859
|
+
externalId: String(sample.externalId ?? "sample-contact"),
|
|
8860
|
+
email: sample.email,
|
|
8861
|
+
tags: Array.isArray(sample.tags) ? sample.tags.map(String) : [],
|
|
8862
|
+
fields: sample.fields && typeof sample.fields === "object" ? sample.fields : {},
|
|
8863
|
+
timezone: typeof sample.timezone === "string" ? sample.timezone : void 0
|
|
8864
|
+
};
|
|
8865
|
+
}
|
|
8866
|
+
res.status(400).json({ error: "validation_failed", message: "contactId (or a sampleContact with an email) is required" });
|
|
8867
|
+
return null;
|
|
8868
|
+
}
|
|
8869
|
+
async function contactDetail(contact) {
|
|
8870
|
+
const [subscription, recentEvents, recentSends, runs, suppressions] = await Promise.all([
|
|
8871
|
+
c.subscriptions.findOne({ externalId: contact.externalId }),
|
|
8872
|
+
c.events.find({ externalId: contact.externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
|
|
8873
|
+
c.sends.find({ externalId: contact.externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
|
|
8874
|
+
c.flowRuns.find({ externalId: contact.externalId }).sort({ enteredAt: -1 }).limit(50).toArray(),
|
|
8875
|
+
c.suppressions.find({ email: contact.email }).toArray()
|
|
8876
|
+
]);
|
|
8877
|
+
return {
|
|
8878
|
+
contact,
|
|
8879
|
+
isTestContact: isTestContact ? isTestContact(contact.email) : null,
|
|
8880
|
+
subscription,
|
|
8881
|
+
suppressions,
|
|
8882
|
+
recentEvents,
|
|
8883
|
+
recentSends: recentSends.map(sendSummary),
|
|
8884
|
+
runs: runs.map(runSummary)
|
|
8885
|
+
};
|
|
8886
|
+
}
|
|
8887
|
+
}
|
|
8888
|
+
var PLACEHOLDER_RE = /\{\{[^{}]*\}\}|\{\{\{[^{}]*\}\}\}/g;
|
|
8889
|
+
var HREF_RE = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
8890
|
+
var GMAIL_CLIP_BYTES = 102 * 1024;
|
|
8891
|
+
async function verifyTemplate(mailer, tpl, contact, opts = {}) {
|
|
8892
|
+
const checks = [];
|
|
8893
|
+
const push = (id, status, detail) => checks.push(detail === void 0 ? { id, status } : { id, status, detail });
|
|
8894
|
+
const base = {
|
|
8895
|
+
template: { slug: tpl.slug, kind: tpl.kind, name: tpl.name },
|
|
8896
|
+
contact: { externalId: contact.externalId, email: contact.email }
|
|
8897
|
+
};
|
|
8898
|
+
const finish = (rendered2, links2) => ({
|
|
8899
|
+
ok: checks.every((k) => k.status !== "fail"),
|
|
8900
|
+
...base,
|
|
8901
|
+
checks,
|
|
8902
|
+
links: { total: links2.length, sample: links2.slice(0, 50) },
|
|
8903
|
+
rendered: rendered2 ? opts.includeRendered ? {
|
|
8904
|
+
subject: rendered2.subject,
|
|
8905
|
+
preheader: rendered2.preheader,
|
|
8906
|
+
html: rendered2.html,
|
|
8907
|
+
plainText: rendered2.plainText,
|
|
8908
|
+
fromEmail: rendered2.fromEmail,
|
|
8909
|
+
fromName: rendered2.fromName,
|
|
8910
|
+
replyTo: rendered2.replyTo
|
|
8911
|
+
} : {
|
|
8912
|
+
subject: rendered2.subject,
|
|
8913
|
+
preheader: rendered2.preheader,
|
|
8914
|
+
htmlBytes: Buffer.byteLength(rendered2.html, "utf8"),
|
|
8915
|
+
textLength: rendered2.plainText.trim().length,
|
|
8916
|
+
fromEmail: rendered2.fromEmail
|
|
8917
|
+
} : null
|
|
8918
|
+
});
|
|
8919
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
8920
|
+
push("published", "fail", "template has no published body (body.html and body.mjml are empty)");
|
|
8921
|
+
return finish(null, []);
|
|
8922
|
+
}
|
|
8923
|
+
push("published", "pass", { publishedAt: tpl.publishedAt ?? null });
|
|
8924
|
+
let out;
|
|
8925
|
+
try {
|
|
8926
|
+
out = await renderForContact(mailer, tpl, contact, {
|
|
8927
|
+
reason: "test",
|
|
8928
|
+
eventProperties: opts.eventProperties,
|
|
8929
|
+
vars: opts.vars
|
|
8930
|
+
});
|
|
8931
|
+
push("vars_resolved", "pass", { keys: Object.keys(out.resolved) });
|
|
8932
|
+
push("render", "pass");
|
|
8933
|
+
} catch (err) {
|
|
8934
|
+
const message = String(err?.message ?? err);
|
|
8935
|
+
push(message.startsWith("varsAdapter") ? "vars_resolved" : "render", "fail", message);
|
|
8936
|
+
return finish(null, []);
|
|
8937
|
+
}
|
|
8938
|
+
const { rendered, unsubscribeUrl } = out;
|
|
8939
|
+
const leftovers = /* @__PURE__ */ new Set();
|
|
8940
|
+
for (const part of [rendered.subject, rendered.preheader, rendered.html, rendered.plainText]) {
|
|
8941
|
+
for (const m of part.matchAll(PLACEHOLDER_RE)) leftovers.add(m[0]);
|
|
8942
|
+
}
|
|
8943
|
+
push("unresolved_placeholders", leftovers.size ? "fail" : "pass", leftovers.size ? { placeholders: [...leftovers] } : void 0);
|
|
8944
|
+
const referenced = referencedPaths(
|
|
8945
|
+
[tpl.subject, tpl.preheader, tpl.body.html, tpl.body.plainText, tpl.body.mjml ?? ""].join("\n"),
|
|
8946
|
+
Object.keys(mailer.config.handlebarsHelpers ?? {})
|
|
8947
|
+
);
|
|
8948
|
+
const missing = [];
|
|
8949
|
+
const empty = [];
|
|
8950
|
+
for (const path3 of referenced) {
|
|
8951
|
+
const value = lookupPath(out.context, path3);
|
|
8952
|
+
if (value === void 0) {
|
|
8953
|
+
const root = path3.split(".")[0];
|
|
8954
|
+
if (exports.RESERVED_VAR_KEYS.includes(root)) empty.push(path3);
|
|
8955
|
+
else missing.push(path3);
|
|
8956
|
+
} else if (value === null || value === "") {
|
|
8957
|
+
empty.push(path3);
|
|
8958
|
+
}
|
|
8959
|
+
}
|
|
8960
|
+
push("unknown_variables", missing.length ? "fail" : "pass", { referenced: referenced.length, missing });
|
|
8961
|
+
push("empty_variables", empty.length ? "warn" : "pass", { empty });
|
|
8962
|
+
const links = extractLinks(rendered.html);
|
|
8963
|
+
const invalid = links.filter((l) => !/^(https?:\/\/|mailto:|tel:)/i.test(l));
|
|
8964
|
+
push("links_absolute", invalid.length ? "fail" : "pass", { total: links.length, invalid });
|
|
8965
|
+
if (tpl.kind === "marketing") {
|
|
8966
|
+
push("unsubscribe_link", rendered.html.includes(unsubscribeUrl) ? "pass" : "fail", {
|
|
8967
|
+
hint: "a marketing template must reference {{unsubscribeUrl}} in its body"
|
|
8968
|
+
});
|
|
8969
|
+
if (mailer.config.senderAddress) {
|
|
8970
|
+
push("sender_address", rendered.html.includes(mailer.config.senderAddress) ? "pass" : "fail", {
|
|
8971
|
+
hint: "CAN-SPAM: reference {{senderAddress}} in the footer"
|
|
8972
|
+
});
|
|
8973
|
+
}
|
|
8974
|
+
}
|
|
8975
|
+
const text = rendered.plainText.trim();
|
|
8976
|
+
push("plain_text", text.length === 0 ? "fail" : text.length < 40 ? "warn" : "pass", { length: text.length });
|
|
8977
|
+
const subject = rendered.subject.trim();
|
|
8978
|
+
push("subject", subject.length === 0 ? "fail" : subject.length > 78 ? "warn" : "pass", { length: subject.length });
|
|
8979
|
+
if (mailer.config.senderDomains && Object.keys(mailer.config.senderDomains).length > 0) {
|
|
8980
|
+
const v = validateSenderDomain(rendered.fromEmail, tpl.kind, mailer.config.senderDomains);
|
|
8981
|
+
push("from_domain", v.ok ? "pass" : "fail", v.ok ? { fromEmail: rendered.fromEmail } : { fromEmail: rendered.fromEmail, code: v.code, reason: v.reason });
|
|
8982
|
+
}
|
|
8983
|
+
const bytes = Buffer.byteLength(rendered.html, "utf8");
|
|
8984
|
+
push("html_size", bytes > GMAIL_CLIP_BYTES ? "warn" : "pass", { bytes, clipAt: GMAIL_CLIP_BYTES });
|
|
8985
|
+
const lint = lintTemplate(
|
|
8986
|
+
{
|
|
8987
|
+
subject: tpl.subject,
|
|
8988
|
+
preheader: tpl.preheader,
|
|
8989
|
+
mjml: tpl.body.mjml ?? "",
|
|
8990
|
+
editorJson: tpl.body.editorJson ?? void 0,
|
|
8991
|
+
html: tpl.body.html,
|
|
8992
|
+
plainText: tpl.body.plainText,
|
|
8993
|
+
kind: tpl.kind,
|
|
8994
|
+
fromEmail: tpl.fromEmail
|
|
8995
|
+
},
|
|
8996
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: opts.varsSchema ?? null }
|
|
8997
|
+
);
|
|
8998
|
+
push("lint", lint.errors.length ? "fail" : lint.warnings.length ? "warn" : "pass", {
|
|
8999
|
+
errors: lint.errors.map((i) => ({ rule: i.rule, message: i.message })),
|
|
9000
|
+
warnings: lint.warnings.map((i) => i.rule)
|
|
9001
|
+
});
|
|
9002
|
+
return finish(rendered, links);
|
|
9003
|
+
}
|
|
9004
|
+
async function renderForContact(mailer, tpl, contact, opts) {
|
|
9005
|
+
let resolved;
|
|
9006
|
+
try {
|
|
9007
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
9008
|
+
reason: opts.reason,
|
|
9009
|
+
templateSlug: tpl.slug,
|
|
9010
|
+
eventProperties: opts.eventProperties
|
|
9011
|
+
});
|
|
9012
|
+
} catch (err) {
|
|
9013
|
+
throw new Error(`varsAdapter.resolve threw: ${String(err?.message ?? err)}`);
|
|
9014
|
+
}
|
|
9015
|
+
const unsubscribeUrl = unsubscribeUrlFor(mailer, contact.email);
|
|
9016
|
+
const context = {
|
|
9017
|
+
...resolved,
|
|
9018
|
+
contact,
|
|
9019
|
+
vars: opts.vars ?? {},
|
|
9020
|
+
event: opts.eventProperties ?? {},
|
|
9021
|
+
unsubscribeUrl,
|
|
9022
|
+
senderAddress: mailer.config.senderAddress
|
|
9023
|
+
};
|
|
9024
|
+
const rendered = await renderTemplate(tpl, context, { helpers: mailer.config.handlebarsHelpers });
|
|
9025
|
+
return { rendered, resolved, unsubscribeUrl, context };
|
|
9026
|
+
}
|
|
9027
|
+
function unsubscribeUrlFor(mailer, email) {
|
|
9028
|
+
const expiresAt = new Date(Date.now() + mailer.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
9029
|
+
const token = signUnsubscribeToken({ email, scope: "marketing", expiresAt }, mailer.config.unsubscribeSecret);
|
|
9030
|
+
return `${mailer.config.publicUrl}/m/unsub/${token}`;
|
|
9031
|
+
}
|
|
9032
|
+
function extractLinks(html) {
|
|
9033
|
+
const out = [];
|
|
9034
|
+
for (const m of html.matchAll(HREF_RE)) {
|
|
9035
|
+
const href = (m[1] ?? m[2] ?? "").trim();
|
|
9036
|
+
if (!href || href.startsWith("#")) continue;
|
|
9037
|
+
out.push(href);
|
|
9038
|
+
}
|
|
9039
|
+
return out;
|
|
9040
|
+
}
|
|
9041
|
+
var BUILTIN_HELPERS = /* @__PURE__ */ new Set([
|
|
9042
|
+
"eq",
|
|
9043
|
+
"ne",
|
|
9044
|
+
"gt",
|
|
9045
|
+
"lt",
|
|
9046
|
+
"gte",
|
|
9047
|
+
"lte",
|
|
9048
|
+
"and",
|
|
9049
|
+
"or",
|
|
9050
|
+
"not",
|
|
9051
|
+
"formatDate",
|
|
9052
|
+
"formatNumber",
|
|
9053
|
+
"formatCurrency",
|
|
9054
|
+
"pluralize",
|
|
9055
|
+
"if",
|
|
9056
|
+
"unless",
|
|
9057
|
+
"each",
|
|
9058
|
+
"with",
|
|
9059
|
+
"else",
|
|
9060
|
+
"lookup",
|
|
9061
|
+
"log",
|
|
9062
|
+
"this",
|
|
9063
|
+
"true",
|
|
9064
|
+
"false",
|
|
9065
|
+
"null",
|
|
9066
|
+
"undefined"
|
|
9067
|
+
]);
|
|
9068
|
+
var MUSTACHE_RE = /\{\{\{?([^{}]*)\}\}\}?/g;
|
|
9069
|
+
var BLOCK_SCOPE_RE = /\{\{#(each|with)\b[\s\S]*?\{\{\/\1\}\}/g;
|
|
9070
|
+
var HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
9071
|
+
var PATH_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
|
|
9072
|
+
function referencedPaths(source, helperNames = []) {
|
|
9073
|
+
const helpers = /* @__PURE__ */ new Set([...BUILTIN_HELPERS, ...helperNames]);
|
|
9074
|
+
const out = /* @__PURE__ */ new Set();
|
|
9075
|
+
const scanned = source.replace(HTML_COMMENT_RE, "").replace(BLOCK_SCOPE_RE, "");
|
|
9076
|
+
for (const m of scanned.matchAll(MUSTACHE_RE)) {
|
|
9077
|
+
let expr = (m[1] ?? "").trim();
|
|
9078
|
+
if (!expr || expr.startsWith("!")) continue;
|
|
9079
|
+
expr = expr.replace(/^[#/^]\s*/, "").replace(/^else\b\s*/, "");
|
|
9080
|
+
for (let tok of expr.split(/[\s()]+/)) {
|
|
9081
|
+
if (!tok) continue;
|
|
9082
|
+
const eq = tok.indexOf("=");
|
|
9083
|
+
if (eq > 0) tok = tok.slice(eq + 1);
|
|
9084
|
+
if (/^['"]/.test(tok) || /^-?\d/.test(tok)) continue;
|
|
9085
|
+
if (tok.startsWith("@") || tok.startsWith("../") || tok.startsWith("this.")) continue;
|
|
9086
|
+
if (helpers.has(tok) || !PATH_RE.test(tok)) continue;
|
|
9087
|
+
out.add(tok);
|
|
9088
|
+
}
|
|
9089
|
+
}
|
|
9090
|
+
return [...out];
|
|
9091
|
+
}
|
|
9092
|
+
function lookupPath(ctx, path3) {
|
|
9093
|
+
let cur = ctx;
|
|
9094
|
+
for (const part of path3.split(".")) {
|
|
9095
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
9096
|
+
cur = cur[part];
|
|
9097
|
+
}
|
|
9098
|
+
return cur;
|
|
9099
|
+
}
|
|
9100
|
+
function testContactMatcher(spec) {
|
|
9101
|
+
if (!spec) return null;
|
|
9102
|
+
if (spec instanceof RegExp) return (email) => spec.test(email);
|
|
9103
|
+
if (typeof spec === "function") return (email) => !!spec(email);
|
|
9104
|
+
return null;
|
|
9105
|
+
}
|
|
9106
|
+
function bearerAuth(tokens) {
|
|
9107
|
+
const hashed = tokens.map((t) => ({ hash: crypto2__default.default.createHash("sha256").update(t.token).digest(), actor: t.actor }));
|
|
9108
|
+
return (req, res, next) => {
|
|
9109
|
+
const header = String(req.headers.authorization ?? "");
|
|
9110
|
+
const m = /^Bearer\s+(\S+)\s*$/i.exec(header);
|
|
9111
|
+
if (!m) {
|
|
9112
|
+
return res.status(401).json({ error: "unauthorized", message: "send Authorization: Bearer <token>" });
|
|
9113
|
+
}
|
|
9114
|
+
const presented = crypto2__default.default.createHash("sha256").update(m[1] ?? "").digest();
|
|
9115
|
+
const match = hashed.find((h) => crypto2__default.default.timingSafeEqual(h.hash, presented));
|
|
9116
|
+
if (!match) return res.status(401).json({ error: "unauthorized", message: "unknown token" });
|
|
9117
|
+
req.actor = match.actor;
|
|
9118
|
+
next();
|
|
9119
|
+
};
|
|
9120
|
+
}
|
|
9121
|
+
async function advanceOnce(runId, ctx, actor, c) {
|
|
9122
|
+
for (let hop = 0; hop < 10; hop += 1) {
|
|
9123
|
+
const run = await c.flowRuns.findOne({ _id: runId });
|
|
9124
|
+
if (!run || run.status !== "active") return false;
|
|
9125
|
+
if (run.nextActionAt && run.nextActionAt.getTime() > Date.now()) {
|
|
9126
|
+
await c.flowRuns.updateOne(
|
|
9127
|
+
{ _id: runId, status: "active" },
|
|
9128
|
+
{
|
|
9129
|
+
$set: { nextActionAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() },
|
|
9130
|
+
$push: {
|
|
9131
|
+
history: {
|
|
9132
|
+
stepIndex: Math.max(0, run.currentStepIndex - 1),
|
|
9133
|
+
action: "wait_completed",
|
|
9134
|
+
at: /* @__PURE__ */ new Date(),
|
|
9135
|
+
details: { forcedBy: actor, scheduledFor: run.nextActionAt }
|
|
9136
|
+
}
|
|
9137
|
+
}
|
|
9138
|
+
}
|
|
9139
|
+
);
|
|
9140
|
+
}
|
|
9141
|
+
const before = (await c.flowRuns.findOne({ _id: runId }))?.history.length ?? 0;
|
|
9142
|
+
await processOneRunStep(runId, ctx);
|
|
9143
|
+
const after = await c.flowRuns.findOne({ _id: runId });
|
|
9144
|
+
if (!after) return false;
|
|
9145
|
+
const last = after.history[after.history.length - 1];
|
|
9146
|
+
const progressed = after.history.length > before;
|
|
9147
|
+
if (progressed && last?.action === "wait_started" && after.status === "active") continue;
|
|
9148
|
+
return after.status === "active" && progressed;
|
|
9149
|
+
}
|
|
9150
|
+
return false;
|
|
9151
|
+
}
|
|
9152
|
+
var WAIT_TARGETS = /* @__PURE__ */ new Set(["sent", "delivered", "opened", "clicked", "terminal"]);
|
|
9153
|
+
function waitTargetReached(send, target) {
|
|
9154
|
+
switch (target) {
|
|
9155
|
+
case "sent":
|
|
9156
|
+
return send.status === "sent" || send.status === "delivered" || !!send.sentAt;
|
|
9157
|
+
case "delivered":
|
|
9158
|
+
return send.status === "delivered" || !!send.deliveredAt;
|
|
9159
|
+
case "opened":
|
|
9160
|
+
return !!send.openedAt;
|
|
9161
|
+
case "clicked":
|
|
9162
|
+
return !!send.firstClickAt;
|
|
9163
|
+
case "terminal":
|
|
9164
|
+
return ["delivered", "bounced", "failed", "suppressed", "cancelled", "complained"].includes(send.status);
|
|
9165
|
+
default:
|
|
9166
|
+
return false;
|
|
9167
|
+
}
|
|
9168
|
+
}
|
|
9169
|
+
function sendSummary(s) {
|
|
9170
|
+
return {
|
|
9171
|
+
id: String(s._id),
|
|
9172
|
+
templateSlug: s.templateSlug,
|
|
9173
|
+
externalId: s.externalId,
|
|
9174
|
+
email: s.emailAtSend,
|
|
9175
|
+
kind: s.kind,
|
|
9176
|
+
status: s.status,
|
|
9177
|
+
provider: s.provider,
|
|
9178
|
+
providerMessageId: s.providerMessageId,
|
|
9179
|
+
subject: s.subject,
|
|
9180
|
+
errorMessage: s.errorMessage,
|
|
9181
|
+
flowRunId: s.flowRunId ? String(s.flowRunId) : null,
|
|
9182
|
+
queuedAt: s.queuedAt,
|
|
9183
|
+
sentAt: s.sentAt,
|
|
9184
|
+
deliveredAt: s.deliveredAt,
|
|
9185
|
+
openedAt: s.openedAt,
|
|
9186
|
+
firstClickAt: s.firstClickAt,
|
|
9187
|
+
bounceType: s.bounceType
|
|
9188
|
+
};
|
|
9189
|
+
}
|
|
9190
|
+
function runSummary(r) {
|
|
9191
|
+
return {
|
|
9192
|
+
id: String(r._id),
|
|
9193
|
+
flowSlug: r.flowSlug,
|
|
9194
|
+
flowVersion: r.flowVersion,
|
|
9195
|
+
externalId: r.externalId,
|
|
9196
|
+
email: r.emailAtEntry,
|
|
9197
|
+
status: r.status,
|
|
9198
|
+
currentStepIndex: r.currentStepIndex,
|
|
9199
|
+
currentBranchPath: r.currentBranchPath,
|
|
9200
|
+
nextActionAt: r.nextActionAt,
|
|
9201
|
+
enteredAt: r.enteredAt,
|
|
9202
|
+
exitedAt: r.exitedAt,
|
|
9203
|
+
exitReason: r.exitReason,
|
|
9204
|
+
triggerEvent: r.triggerEvent ?? null,
|
|
9205
|
+
history: r.history
|
|
9206
|
+
};
|
|
9207
|
+
}
|
|
9208
|
+
function objectOrUndefined(v) {
|
|
9209
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
9210
|
+
}
|
|
9211
|
+
function sleep2(ms) {
|
|
9212
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
9213
|
+
}
|
|
9214
|
+
function wrap2(fn) {
|
|
9215
|
+
return (req, res, next) => {
|
|
9216
|
+
Promise.resolve(fn(req, res)).catch(next);
|
|
9217
|
+
};
|
|
9218
|
+
}
|
|
9219
|
+
var ENDPOINTS = [
|
|
9220
|
+
{ method: "GET", path: "/", summary: "This listing, the package version and the actor behind the token." },
|
|
9221
|
+
{ method: "*", path: "/api/*", summary: "The full admin JSON API (flows, templates, contacts, sends, health, setup-status, \u2026) with this token as the actor." },
|
|
9222
|
+
{ method: "POST", path: "/templates/:slug/verify", summary: "Render the published template as a contact ({contactId} or {sampleContact}) and run named checks: placeholders, links, unsubscribe, sender address, plain text, subject, from domain, size, lint. {includeRendered: true} returns the HTML." },
|
|
9223
|
+
{ method: "POST", path: "/templates/verify-all", summary: "Verify every template (or {slugs}) for each of {contactIds}; a matrix of pass/fail." },
|
|
9224
|
+
{ method: "POST", path: "/templates/:slug/render", summary: "Render for a contact and return subject, preheader, HTML, plain text, resolved vars and the signed unsubscribe URL." },
|
|
9225
|
+
{ method: "POST", path: "/templates/:slug/send", summary: 'A real send through the pipeline to a test contact ({contactId}); dispatched inline unless {dispatch: "queue"}. Returns the sendId.', testContactsOnly: true },
|
|
9226
|
+
{ method: "GET", path: "/sends/:id/wait?status=delivered&timeoutMs=30000", summary: "Long-poll a send until it reaches sent | delivered | opened | clicked | terminal, with its webhook events." },
|
|
9227
|
+
{ method: "POST", path: "/sends/:id/dispatch", summary: "Dispatch a queued send now (test contacts only).", testContactsOnly: true },
|
|
9228
|
+
{ method: "POST", path: "/flows/:slug/simulate", summary: "Dry-run the flow for {contactId} from {at} with {eventProperties}: the path taken, every gate verdict, projected send times, where it ends. Writes nothing." },
|
|
9229
|
+
{ method: "POST", path: "/flows/:slug/arm", summary: "Enable the flow for FUTURE events: stamps the trigger watermark ({since} or now) in the same write. Requires {confirm: true}." },
|
|
9230
|
+
{ method: "POST", path: "/flows/:slug/disarm", summary: "Disable the flow (pause). In-flight runs continue." },
|
|
9231
|
+
{ method: "POST", path: "/flows/:slug/gate", summary: "Publish a canary version whose first step exits anyone without {tag}." },
|
|
9232
|
+
{ method: "POST", path: "/flows/:slug/ungate", summary: "Restore the newest ungated version." },
|
|
9233
|
+
{ method: "GET", path: "/runs?externalId=&flowSlug=&status=&limit=", summary: "List flow runs." },
|
|
9234
|
+
{ method: "GET", path: "/runs/:id", summary: "One run with its history and sends." },
|
|
9235
|
+
{ method: "POST", path: "/runs/:id/advance", summary: "Walk a test contact's run forward now, skipping the wait in front of each of {steps} transitions; sends are dispatched inline.", testContactsOnly: true },
|
|
9236
|
+
{ method: "POST", path: "/runs/:id/cancel", summary: "Exit an active run and cancel its queued sends." },
|
|
9237
|
+
{ method: "POST", path: "/events", summary: "Fire {name} for a test contact {externalId} with {properties} and optional {dedupeKey}.", testContactsOnly: true },
|
|
9238
|
+
{ method: "GET", path: "/contacts/:externalId", summary: "Contact with subscription, suppressions, recent events, sends and runs." },
|
|
9239
|
+
{ method: "GET", path: "/contacts/by-email/:email", summary: "Same, looked up by email." },
|
|
9240
|
+
{ method: "GET", path: "/contacts/:externalId/unsubscribe-url", summary: "A signed one-click unsubscribe URL for a test contact, to exercise POST /m/unsub/:token.", testContactsOnly: true },
|
|
9241
|
+
{ method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
|
|
9242
|
+
{ method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
|
|
9243
|
+
{ method: "POST", path: "/contacts/:externalId/reset", summary: "Delete a test contact's runs, sends, events ({events: [names]} to narrow) and suppressions, then resubscribe. Each part can be turned off with false.", testContactsOnly: true },
|
|
9244
|
+
{ method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
|
|
9245
|
+
{ method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
|
|
9246
|
+
{ method: "GET", path: "/status", summary: "One document with setup checks, health, every flow (enabled, version, watermark, gate, active runs), every template, and 24h counts." }
|
|
9247
|
+
];
|
|
7869
9248
|
var ALLOWED_EXTENSIONS = [".zip", ".gz", ".xml"];
|
|
7870
9249
|
var DEFAULT_PATH = "/inbound/dmarc";
|
|
7871
9250
|
var DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
@@ -8577,45 +9956,59 @@ var DEDUPE_POLICIES = [
|
|
|
8577
9956
|
];
|
|
8578
9957
|
|
|
8579
9958
|
// src/server/index.ts
|
|
8580
|
-
var
|
|
9959
|
+
var VERSION2 = "0.16.1" ;
|
|
8581
9960
|
|
|
8582
9961
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
8583
9962
|
exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;
|
|
8584
9963
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|
|
9964
|
+
exports.FlowOperationError = FlowOperationError;
|
|
9965
|
+
exports.MIN_AGENT_TOKEN_LENGTH = MIN_AGENT_TOKEN_LENGTH;
|
|
8585
9966
|
exports.Mailer = Mailer;
|
|
8586
9967
|
exports.NullProvider = NullProvider;
|
|
8587
9968
|
exports.PREDICATE_KINDS = PREDICATE_KINDS;
|
|
8588
9969
|
exports.SEGMENT_FILTER_KINDS = SEGMENT_FILTER_KINDS;
|
|
8589
9970
|
exports.TRACKING_SIG_LENGTH = TRACKING_SIG_LENGTH;
|
|
8590
|
-
exports.VERSION =
|
|
9971
|
+
exports.VERSION = VERSION2;
|
|
8591
9972
|
exports.applyTracking = applyTracking;
|
|
8592
9973
|
exports.applyWebhookEvent = applyWebhookEvent;
|
|
9974
|
+
exports.armFlow = armFlow;
|
|
8593
9975
|
exports.compileMailyTemplate = compileMailyTemplate;
|
|
8594
9976
|
exports.compileTemplate = compileTemplate;
|
|
8595
9977
|
exports.computeDeliveryTime = computeDeliveryTime;
|
|
9978
|
+
exports.createAdminApiRouter = createAdminApiRouter;
|
|
8596
9979
|
exports.createAdminRouter = createAdminRouter;
|
|
9980
|
+
exports.createAgentRouter = createAgentRouter;
|
|
8597
9981
|
exports.createPublicRouter = createPublicRouter;
|
|
8598
9982
|
exports.defaultFlowStep = defaultFlowStep;
|
|
8599
9983
|
exports.defaultPredicate = defaultPredicate;
|
|
8600
9984
|
exports.defaultSegmentFilter = defaultSegmentFilter;
|
|
8601
9985
|
exports.defineVars = defineVars;
|
|
8602
9986
|
exports.derivePlaintext = derivePlaintext;
|
|
9987
|
+
exports.disarmFlow = disarmFlow;
|
|
8603
9988
|
exports.dispatchSend = dispatchSend;
|
|
8604
9989
|
exports.drainPendingUnsubscribes = drainPendingUnsubscribes;
|
|
8605
9990
|
exports.ensureIndexes = ensureIndexes;
|
|
9991
|
+
exports.gateFlow = gateFlow;
|
|
8606
9992
|
exports.getCollections = getCollections;
|
|
9993
|
+
exports.isCanaryGate = isCanaryGate;
|
|
8607
9994
|
exports.predicateKind = predicateKind;
|
|
8608
9995
|
exports.processNewlyFiredEventTriggers = processNewlyFiredEventTriggers;
|
|
8609
9996
|
exports.processOneRunStep = processOneRunStep;
|
|
9997
|
+
exports.referencedPaths = referencedPaths;
|
|
9998
|
+
exports.renderForContact = renderForContact;
|
|
8610
9999
|
exports.renderTemplate = renderTemplate;
|
|
8611
10000
|
exports.runTick = runTick;
|
|
8612
10001
|
exports.sendgridInboundParser = sendgridInboundParser;
|
|
8613
10002
|
exports.sha256Hex = sha256Hex;
|
|
8614
10003
|
exports.signTrackingToken = signTrackingToken;
|
|
8615
10004
|
exports.signUnsubscribeToken = signUnsubscribeToken;
|
|
10005
|
+
exports.simulateFlow = simulateFlow;
|
|
10006
|
+
exports.stampWatermarkIfNull = stampWatermarkIfNull;
|
|
8616
10007
|
exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
|
|
10008
|
+
exports.ungateFlow = ungateFlow;
|
|
8617
10009
|
exports.validateSenderDomain = validateSenderDomain;
|
|
8618
10010
|
exports.varsJsonSchema = varsJsonSchema;
|
|
10011
|
+
exports.verifyTemplate = verifyTemplate;
|
|
8619
10012
|
exports.verifyTrackingToken = verifyTrackingToken;
|
|
8620
10013
|
exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
|
|
8621
10014
|
//# sourceMappingURL=index.cjs.map
|