mailery 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/admin/spa/index-B88z7X-U.css +1 -0
- package/dist/admin/spa/index-m4wK04gU.js +9 -0
- package/dist/admin/spa/index-m4wK04gU.js.map +1 -0
- package/dist/admin/spa/index.html +2 -2
- package/dist/admin/spa/template-editor-CKQLM0Me.js +156 -0
- package/dist/admin/spa/{template-editor-pGcCFnJ7.js.map → template-editor-CKQLM0Me.js.map} +1 -1
- package/dist/cli.cjs +5 -10
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +2315 -153
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +669 -20
- package/dist/index.d.ts +669 -20
- package/dist/index.js +2310 -168
- package/dist/index.js.map +1 -1
- package/dist/{null-DDg3ojHe.d.cts → null-CDlseQxO.d.cts} +136 -6
- package/dist/{null-DDg3ojHe.d.ts → null-CDlseQxO.d.ts} +136 -6
- package/dist/testing.cjs +590 -190
- 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 +589 -191
- package/dist/testing.js.map +1 -1
- package/package.json +15 -10
- package/dist/admin/spa/index-B6riKdpB.css +0 -1
- package/dist/admin/spa/index-l7m1PyMb.js +0 -41
- package/dist/admin/spa/index-l7m1PyMb.js.map +0 -1
- package/dist/admin/spa/template-editor-pGcCFnJ7.js +0 -502
package/dist/index.cjs
CHANGED
|
@@ -11,11 +11,12 @@ var mjml2html = require('mjml');
|
|
|
11
11
|
var dns = require('dns/promises');
|
|
12
12
|
var net = require('net');
|
|
13
13
|
var psl = require('psl');
|
|
14
|
+
var fastXmlParser = require('fast-xml-parser');
|
|
15
|
+
var fs = require('fs');
|
|
16
|
+
var path = require('path');
|
|
14
17
|
var express = require('express');
|
|
15
18
|
var multer = require('multer');
|
|
16
|
-
var path = require('path');
|
|
17
19
|
var url = require('url');
|
|
18
|
-
var fs = require('fs');
|
|
19
20
|
|
|
20
21
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
21
22
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -28,21 +29,20 @@ var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
|
|
|
28
29
|
var dns__default = /*#__PURE__*/_interopDefault(dns);
|
|
29
30
|
var net__default = /*#__PURE__*/_interopDefault(net);
|
|
30
31
|
var psl__default = /*#__PURE__*/_interopDefault(psl);
|
|
32
|
+
var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
33
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
31
34
|
var express__default = /*#__PURE__*/_interopDefault(express);
|
|
32
35
|
var multer__default = /*#__PURE__*/_interopDefault(multer);
|
|
33
|
-
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
34
|
-
var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
35
36
|
|
|
36
37
|
var __defProp = Object.defineProperty;
|
|
37
38
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
39
|
+
var __esm = (fn, res, err) => function __init() {
|
|
40
|
+
if (err) throw err[0];
|
|
41
|
+
try {
|
|
42
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw err = [e], e;
|
|
45
|
+
}
|
|
46
46
|
};
|
|
47
47
|
var __export = (target, all) => {
|
|
48
48
|
for (var name in all)
|
|
@@ -258,6 +258,31 @@ var init_mongo = __esm({
|
|
|
258
258
|
}
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
// src/server/providers/webhook-tolerance.ts
|
|
262
|
+
function resolveWebhookToleranceSeconds(option) {
|
|
263
|
+
if (option === false || option === 0) return 0;
|
|
264
|
+
if (option === void 0 || option === null) return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
265
|
+
if (typeof option !== "number" || !Number.isFinite(option) || option < 0) {
|
|
266
|
+
return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
267
|
+
}
|
|
268
|
+
return option;
|
|
269
|
+
}
|
|
270
|
+
function isWebhookTimestampFresh(rawTimestamp, toleranceSeconds, now = /* @__PURE__ */ new Date()) {
|
|
271
|
+
if (toleranceSeconds === 0) return true;
|
|
272
|
+
if (!rawTimestamp) return false;
|
|
273
|
+
if (!/^\d{1,15}$/.test(rawTimestamp)) return false;
|
|
274
|
+
const timestampMs = Number(rawTimestamp) * 1e3;
|
|
275
|
+
if (!Number.isFinite(timestampMs)) return false;
|
|
276
|
+
const deltaMs = Math.abs(now.getTime() - timestampMs);
|
|
277
|
+
return deltaMs <= toleranceSeconds * 1e3;
|
|
278
|
+
}
|
|
279
|
+
var DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
280
|
+
var init_webhook_tolerance = __esm({
|
|
281
|
+
"src/server/providers/webhook-tolerance.ts"() {
|
|
282
|
+
DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
261
286
|
// src/server/providers/sendgrid.ts
|
|
262
287
|
var sendgrid_exports = {};
|
|
263
288
|
__export(sendgrid_exports, {
|
|
@@ -304,6 +329,7 @@ function mapEventType(sgEvent) {
|
|
|
304
329
|
var SG_SIG_HEADER, SG_TS_HEADER; exports.SendGridProvider = void 0;
|
|
305
330
|
var init_sendgrid = __esm({
|
|
306
331
|
"src/server/providers/sendgrid.ts"() {
|
|
332
|
+
init_webhook_tolerance();
|
|
307
333
|
SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
|
|
308
334
|
SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
|
|
309
335
|
exports.SendGridProvider = class {
|
|
@@ -311,10 +337,13 @@ var init_sendgrid = __esm({
|
|
|
311
337
|
this.opts = opts;
|
|
312
338
|
sgMail__default.default.setApiKey(opts.apiKey);
|
|
313
339
|
this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
|
|
340
|
+
this.webhookToleranceSeconds = resolveWebhookToleranceSeconds(opts.webhookToleranceSeconds);
|
|
314
341
|
}
|
|
315
342
|
opts;
|
|
316
343
|
name = "sendgrid";
|
|
317
344
|
sendRatePerSecond;
|
|
345
|
+
/** Resolved replay window in seconds; `0` means the check is disabled. */
|
|
346
|
+
webhookToleranceSeconds;
|
|
318
347
|
async send(args) {
|
|
319
348
|
const msg = {
|
|
320
349
|
to: args.to,
|
|
@@ -354,10 +383,11 @@ var init_sendgrid = __esm({
|
|
|
354
383
|
try {
|
|
355
384
|
const verifier = crypto2__default.default.createVerify("sha256");
|
|
356
385
|
verifier.update(payload);
|
|
357
|
-
|
|
386
|
+
if (!verifier.verify(this.opts.webhookVerificationKey, sig, "base64")) return false;
|
|
358
387
|
} catch {
|
|
359
388
|
return false;
|
|
360
389
|
}
|
|
390
|
+
return isWebhookTimestampFresh(ts, this.webhookToleranceSeconds);
|
|
361
391
|
}
|
|
362
392
|
parseWebhookEvents(payload) {
|
|
363
393
|
if (!Array.isArray(payload)) return [];
|
|
@@ -487,6 +517,10 @@ var flowStepSchema = zod.z.lazy(
|
|
|
487
517
|
}),
|
|
488
518
|
zod.z.object({
|
|
489
519
|
type: zod.z.literal("webhook"),
|
|
520
|
+
// `url` is validated for shape only and is deliberately never templated —
|
|
521
|
+
// see the comment on handleWebhookStep in src/server/runner/step.ts, and
|
|
522
|
+
// INVARIANT 16. Rendering contact/event data into this field would turn an
|
|
523
|
+
// admin-authored flow into a server-side request forgery primitive.
|
|
490
524
|
url: zod.z.string().url(),
|
|
491
525
|
method: zod.z.enum(["POST", "PUT"]).optional(),
|
|
492
526
|
payload: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
|
|
@@ -580,6 +614,7 @@ var DEFAULTS = {
|
|
|
580
614
|
requireDoubleOptIn: false,
|
|
581
615
|
unsubscribeTokenLifetimeDays: 90,
|
|
582
616
|
transactionalRespectUnsubscribe: false,
|
|
617
|
+
unsubscribeWriteTimeoutMs: 5e3,
|
|
583
618
|
doiTemplateSlug: "doi-confirmation",
|
|
584
619
|
doiTokenLifetimeDays: 7,
|
|
585
620
|
broadcastConfirmationThreshold: 1e3,
|
|
@@ -596,7 +631,9 @@ var DEFAULTS = {
|
|
|
596
631
|
trackOpens: true,
|
|
597
632
|
trackClicks: true,
|
|
598
633
|
storeTrackingIp: false,
|
|
599
|
-
storeRenderedBody: false
|
|
634
|
+
storeRenderedBody: false,
|
|
635
|
+
requireSignedTrackingUrls: false,
|
|
636
|
+
trackingUrlLifetimeDays: 0
|
|
600
637
|
};
|
|
601
638
|
var CIRCUIT_BREAKER_DEFAULTS = {
|
|
602
639
|
hardBounceRatePctTrip: 2,
|
|
@@ -613,6 +650,7 @@ function resolveConfig(c) {
|
|
|
613
650
|
requireDoubleOptIn: c.requireDoubleOptIn ?? DEFAULTS.requireDoubleOptIn,
|
|
614
651
|
unsubscribeTokenLifetimeDays: c.unsubscribeTokenLifetimeDays ?? DEFAULTS.unsubscribeTokenLifetimeDays,
|
|
615
652
|
transactionalRespectUnsubscribe: c.transactionalRespectUnsubscribe ?? DEFAULTS.transactionalRespectUnsubscribe,
|
|
653
|
+
unsubscribeWriteTimeoutMs: c.unsubscribeWriteTimeoutMs ?? DEFAULTS.unsubscribeWriteTimeoutMs,
|
|
616
654
|
doiTemplateSlug: c.doiTemplateSlug ?? DEFAULTS.doiTemplateSlug,
|
|
617
655
|
doiTokenLifetimeDays: c.doiTokenLifetimeDays ?? DEFAULTS.doiTokenLifetimeDays,
|
|
618
656
|
broadcastConfirmationThreshold: c.broadcastConfirmationThreshold ?? DEFAULTS.broadcastConfirmationThreshold,
|
|
@@ -630,6 +668,8 @@ function resolveConfig(c) {
|
|
|
630
668
|
trackClicks: c.trackClicks ?? DEFAULTS.trackClicks,
|
|
631
669
|
storeTrackingIp: c.storeTrackingIp ?? DEFAULTS.storeTrackingIp,
|
|
632
670
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
671
|
+
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
672
|
+
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
633
673
|
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
634
674
|
};
|
|
635
675
|
}
|
|
@@ -832,6 +872,22 @@ function isoDay(d) {
|
|
|
832
872
|
function randomId() {
|
|
833
873
|
return crypto2__default.default.randomBytes(8).toString("hex");
|
|
834
874
|
}
|
|
875
|
+
|
|
876
|
+
// src/server/provider-lookup.ts
|
|
877
|
+
var PROVIDER_METHODS = ["send", "verifyWebhook", "parseWebhookEvents"];
|
|
878
|
+
function isMailProvider(candidate) {
|
|
879
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
880
|
+
const p = candidate;
|
|
881
|
+
return PROVIDER_METHODS.every((m) => typeof p[m] === "function");
|
|
882
|
+
}
|
|
883
|
+
function resolveProvider(providers, name) {
|
|
884
|
+
if (typeof name !== "string" || !Object.hasOwn(providers, name)) return null;
|
|
885
|
+
const candidate = providers[name];
|
|
886
|
+
return isMailProvider(candidate) ? candidate : null;
|
|
887
|
+
}
|
|
888
|
+
function registeredProviderNames(providers) {
|
|
889
|
+
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
890
|
+
}
|
|
835
891
|
function signUnsubscribeToken(payload, secret) {
|
|
836
892
|
const body = JSON.stringify({
|
|
837
893
|
e: payload.email.toLowerCase(),
|
|
@@ -870,6 +926,28 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
|
|
|
870
926
|
expiresAt: new Date(body.x)
|
|
871
927
|
};
|
|
872
928
|
}
|
|
929
|
+
var TRACKING_SCOPES = ["open", "click"];
|
|
930
|
+
var TRACKING_SIG_LENGTH = 12;
|
|
931
|
+
function trackingMessage(scope, params) {
|
|
932
|
+
const tail = params.linkId === void 0 ? "" : `:${params.linkId}`;
|
|
933
|
+
return `mailery.track.v1:${scope}:${params.sendId}${tail}`;
|
|
934
|
+
}
|
|
935
|
+
function signTrackingToken(scope, params, secret) {
|
|
936
|
+
if (!TRACKING_SCOPES.includes(scope)) {
|
|
937
|
+
throw new Error(`signTrackingToken: unknown scope ${String(scope)}`);
|
|
938
|
+
}
|
|
939
|
+
const mac = crypto2__default.default.createHmac("sha256", secret).update(trackingMessage(scope, params)).digest();
|
|
940
|
+
return b64url(mac).slice(0, TRACKING_SIG_LENGTH);
|
|
941
|
+
}
|
|
942
|
+
function verifyTrackingToken(token, scope, params, secret) {
|
|
943
|
+
if (typeof token !== "string") return false;
|
|
944
|
+
if (!TRACKING_SCOPES.includes(scope)) return false;
|
|
945
|
+
if (token.length !== TRACKING_SIG_LENGTH) return false;
|
|
946
|
+
const expected = Buffer.from(signTrackingToken(scope, params, secret), "utf8");
|
|
947
|
+
const actual = Buffer.from(token, "utf8");
|
|
948
|
+
if (expected.length !== actual.length) return false;
|
|
949
|
+
return crypto2__default.default.timingSafeEqual(expected, actual);
|
|
950
|
+
}
|
|
873
951
|
function b64url(buf) {
|
|
874
952
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
875
953
|
}
|
|
@@ -913,6 +991,39 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
913
991
|
if (body.x < now.getTime()) return null;
|
|
914
992
|
return { externalId: body.i, expiresAt: new Date(body.x) };
|
|
915
993
|
}
|
|
994
|
+
|
|
995
|
+
// src/server/unsubscribe.ts
|
|
996
|
+
async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
|
|
997
|
+
const normalized = input.email;
|
|
998
|
+
await collections.suppressions.updateOne(
|
|
999
|
+
{ email: normalized, scope: input.scope },
|
|
1000
|
+
{
|
|
1001
|
+
$setOnInsert: {
|
|
1002
|
+
email: normalized,
|
|
1003
|
+
emailHash: sha256Hex(normalized),
|
|
1004
|
+
scope: input.scope,
|
|
1005
|
+
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
1006
|
+
reason: "unsubscribed",
|
|
1007
|
+
source: input.source,
|
|
1008
|
+
notes: input.notes ?? null,
|
|
1009
|
+
addedAt: now,
|
|
1010
|
+
expiresAt: null
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
{ upsert: true }
|
|
1014
|
+
);
|
|
1015
|
+
await collections.subscriptions.updateOne(
|
|
1016
|
+
{ emailAtSubscribe: normalized },
|
|
1017
|
+
{
|
|
1018
|
+
$set: {
|
|
1019
|
+
status: "unsubscribed",
|
|
1020
|
+
unsubscribedAt: now,
|
|
1021
|
+
unsubscribeReason: input.reason,
|
|
1022
|
+
updatedAt: now
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
916
1027
|
var QUEUE_NAMES = {
|
|
917
1028
|
tick: "mailer-tick",
|
|
918
1029
|
advance: "mailer-advance",
|
|
@@ -1745,7 +1856,7 @@ async function suppressOnce(ctx, email, reason, scope) {
|
|
|
1745
1856
|
}
|
|
1746
1857
|
|
|
1747
1858
|
// src/server/runner/predicate.ts
|
|
1748
|
-
var
|
|
1859
|
+
var DEFAULT_BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1749
1860
|
async function evaluatePredicate(predicate, ctx) {
|
|
1750
1861
|
const p = predicate;
|
|
1751
1862
|
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
@@ -1813,17 +1924,35 @@ async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
|
1813
1924
|
return await ctx.collections.sends.countDocuments(filter);
|
|
1814
1925
|
}
|
|
1815
1926
|
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1927
|
+
const botRe = ctx.botFilter?.userAgentPattern ?? DEFAULT_BOT_UA_RE;
|
|
1928
|
+
const minOpenDelayMs = ctx.botFilter?.minOpenDelayMs ?? 0;
|
|
1816
1929
|
let n = 0;
|
|
1817
1930
|
for (const s of docs) {
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
}
|
|
1822
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1823
|
-
if (hasHumanClick) n++;
|
|
1931
|
+
const doc = s;
|
|
1932
|
+
const human = kind === "opened" ? hasHumanOpen(doc, botRe, minOpenDelayMs) : (doc.clickedLinks ?? []).some((c) => !isBotUserAgent(c.userAgent, botRe));
|
|
1933
|
+
if (human) n++;
|
|
1824
1934
|
}
|
|
1825
1935
|
return n;
|
|
1826
1936
|
}
|
|
1937
|
+
function isBotUserAgent(ua, botRe) {
|
|
1938
|
+
if (typeof ua !== "string" || ua.trim() === "") return false;
|
|
1939
|
+
return botRe.test(ua);
|
|
1940
|
+
}
|
|
1941
|
+
function hasHumanOpen(send, botRe, minOpenDelayMs) {
|
|
1942
|
+
const opens = send.opens && send.opens.length > 0 ? send.opens : send.openedAt ? [{ openedAt: send.openedAt, userAgent: null }] : [];
|
|
1943
|
+
return opens.some((o) => {
|
|
1944
|
+
if (isBotUserAgent(o.userAgent, botRe)) return false;
|
|
1945
|
+
if (isPrefetchOpen(o.openedAt, send.queuedAt, minOpenDelayMs)) return false;
|
|
1946
|
+
return true;
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
function isPrefetchOpen(openedAt, queuedAt, minOpenDelayMs) {
|
|
1950
|
+
if (!minOpenDelayMs || minOpenDelayMs <= 0) return false;
|
|
1951
|
+
if (!(openedAt instanceof Date) || !(queuedAt instanceof Date)) return false;
|
|
1952
|
+
const delta = openedAt.getTime() - queuedAt.getTime();
|
|
1953
|
+
if (!Number.isFinite(delta) || delta < 0) return false;
|
|
1954
|
+
return delta < minOpenDelayMs;
|
|
1955
|
+
}
|
|
1827
1956
|
function effectiveLowerBound(ctx, opts) {
|
|
1828
1957
|
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1829
1958
|
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
@@ -1989,12 +2118,14 @@ function applyTracking(html, opts) {
|
|
|
1989
2118
|
seen.set(url, linkId);
|
|
1990
2119
|
links.push({ linkId, url });
|
|
1991
2120
|
}
|
|
1992
|
-
const
|
|
2121
|
+
const sig = opts.signingSecret ? `/${signTrackingToken("click", { sendId: opts.sendId, linkId }, opts.signingSecret)}` : "";
|
|
2122
|
+
const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}${sig}`;
|
|
1993
2123
|
return `<a${pre}href="${newHref}"${post}>`;
|
|
1994
2124
|
});
|
|
1995
2125
|
}
|
|
1996
2126
|
if (opts.trackOpens) {
|
|
1997
|
-
const
|
|
2127
|
+
const sig = opts.signingSecret ? `.${signTrackingToken("open", { sendId: opts.sendId }, opts.signingSecret)}` : "";
|
|
2128
|
+
const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}${sig}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
|
|
1998
2129
|
if (/<\/body>/i.test(out)) {
|
|
1999
2130
|
out = out.replace(/<\/body>/i, `${pixel}</body>`);
|
|
2000
2131
|
} else {
|
|
@@ -2005,14 +2136,22 @@ function applyTracking(html, opts) {
|
|
|
2005
2136
|
}
|
|
2006
2137
|
function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
2007
2138
|
if (!url) return true;
|
|
2008
|
-
if (url.startsWith("mailto:")) return true;
|
|
2009
|
-
if (url.startsWith("tel:")) return true;
|
|
2010
2139
|
if (url.startsWith("#")) return true;
|
|
2011
2140
|
if (url.startsWith("{{") || url.startsWith("{")) return true;
|
|
2012
2141
|
if (preserve.has(url.trim())) return true;
|
|
2013
2142
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
2143
|
+
if (!isHttpUrl(url)) return true;
|
|
2014
2144
|
return false;
|
|
2015
2145
|
}
|
|
2146
|
+
function isHttpUrl(url) {
|
|
2147
|
+
let parsed;
|
|
2148
|
+
try {
|
|
2149
|
+
parsed = new URL(url.trim());
|
|
2150
|
+
} catch {
|
|
2151
|
+
return false;
|
|
2152
|
+
}
|
|
2153
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
2154
|
+
}
|
|
2016
2155
|
var NAMED_ENTITIES = {
|
|
2017
2156
|
amp: "&",
|
|
2018
2157
|
lt: "<",
|
|
@@ -2233,7 +2372,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2233
2372
|
publicUrl: ctx.config.publicUrl,
|
|
2234
2373
|
trackOpens: template.trackOpens ?? ctx.config.trackOpens,
|
|
2235
2374
|
trackClicks: template.trackClicks ?? ctx.config.trackClicks,
|
|
2236
|
-
preserveUrls: [renderCtx.unsubscribeUrl]
|
|
2375
|
+
preserveUrls: [renderCtx.unsubscribeUrl],
|
|
2376
|
+
signingSecret: ctx.config.unsubscribeSecret
|
|
2237
2377
|
});
|
|
2238
2378
|
await ctx.collections.sends.updateOne(
|
|
2239
2379
|
{ _id: send._id },
|
|
@@ -2251,9 +2391,14 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2251
2391
|
}
|
|
2252
2392
|
}
|
|
2253
2393
|
);
|
|
2254
|
-
const provider = ctx.providers
|
|
2394
|
+
const provider = resolveProvider(ctx.providers, send.provider);
|
|
2255
2395
|
if (!provider) {
|
|
2256
|
-
|
|
2396
|
+
const known = registeredProviderNames(ctx.providers);
|
|
2397
|
+
await markFailed(
|
|
2398
|
+
send._id,
|
|
2399
|
+
`provider_unknown: no provider is registered as "${send.provider}". Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
|
|
2400
|
+
ctx
|
|
2401
|
+
);
|
|
2257
2402
|
return;
|
|
2258
2403
|
}
|
|
2259
2404
|
const headers = send.kind === "marketing" ? {
|
|
@@ -2452,7 +2597,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2452
2597
|
}
|
|
2453
2598
|
async function handleCondition(run, step, contact, ctx) {
|
|
2454
2599
|
if (!contact) return;
|
|
2455
|
-
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
|
|
2600
|
+
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections, botFilter: ctx.config.botFilter });
|
|
2456
2601
|
if (result) {
|
|
2457
2602
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
2458
2603
|
return;
|
|
@@ -2465,7 +2610,7 @@ async function handleCondition(run, step, contact, ctx) {
|
|
|
2465
2610
|
}
|
|
2466
2611
|
async function handleBranch(run, step, contact, ctx) {
|
|
2467
2612
|
if (!contact) return;
|
|
2468
|
-
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
|
|
2613
|
+
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections, botFilter: ctx.config.botFilter });
|
|
2469
2614
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
2470
2615
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2471
2616
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -3609,8 +3754,6 @@ function nthSundayOfMonth(year, month0, n) {
|
|
|
3609
3754
|
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
3610
3755
|
return firstSunday + (n - 1) * 7;
|
|
3611
3756
|
}
|
|
3612
|
-
|
|
3613
|
-
// src/server/runner/dmarc.ts
|
|
3614
3757
|
var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
|
|
3615
3758
|
function assertSafeZipEntryName(rawName) {
|
|
3616
3759
|
if (typeof rawName !== "string" || rawName.length === 0) {
|
|
@@ -3684,8 +3827,7 @@ async function extractDmarcXmls(buffer, filename) {
|
|
|
3684
3827
|
return [buffer.toString("utf8")];
|
|
3685
3828
|
}
|
|
3686
3829
|
function parseDmarcReport(xml) {
|
|
3687
|
-
const
|
|
3688
|
-
const parser = new XMLParser({
|
|
3830
|
+
const parser = new fastXmlParser.XMLParser({
|
|
3689
3831
|
ignoreAttributes: false,
|
|
3690
3832
|
parseAttributeValue: false,
|
|
3691
3833
|
parseTagValue: false,
|
|
@@ -3775,7 +3917,7 @@ function toArray(v) {
|
|
|
3775
3917
|
if (v === void 0 || v === null) return [];
|
|
3776
3918
|
return Array.isArray(v) ? v : [v];
|
|
3777
3919
|
}
|
|
3778
|
-
async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
3920
|
+
async function ingestDmarcAttachment(ctx, buffer, filename, opts = {}) {
|
|
3779
3921
|
const xmls = await extractDmarcXmls(buffer, filename);
|
|
3780
3922
|
if (xmls.length === 0) throw new Error("attachment contained no XML payload");
|
|
3781
3923
|
const results = [];
|
|
@@ -3783,6 +3925,9 @@ async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
|
3783
3925
|
for (const xml of xmls) {
|
|
3784
3926
|
try {
|
|
3785
3927
|
const parsed = parseDmarcReport(xml);
|
|
3928
|
+
if (opts.allowDomain && !opts.allowDomain(parsed.report.domain)) {
|
|
3929
|
+
throw new Error(`DMARC report is for domain "${parsed.report.domain}", which this deployment does not send from`);
|
|
3930
|
+
}
|
|
3786
3931
|
results.push(await ingestParsedDmarcReport(ctx, parsed));
|
|
3787
3932
|
} catch (err) {
|
|
3788
3933
|
if (!firstError) firstError = err;
|
|
@@ -3893,6 +4038,260 @@ function suggestPolicyProgression(input) {
|
|
|
3893
4038
|
}
|
|
3894
4039
|
return null;
|
|
3895
4040
|
}
|
|
4041
|
+
var DRAINING_INFIX = ".draining.";
|
|
4042
|
+
var ADOPT_AFTER_MS = 5 * 60 * 1e3;
|
|
4043
|
+
var JOURNAL_WARN_BYTES = 8 * 1024 * 1024;
|
|
4044
|
+
var O_NOFOLLOW = fs__default.default.constants.O_NOFOLLOW ?? 0;
|
|
4045
|
+
var APPEND_FLAGS = fs__default.default.constants.O_WRONLY | fs__default.default.constants.O_CREAT | fs__default.default.constants.O_APPEND | O_NOFOLLOW;
|
|
4046
|
+
function appendPendingUnsub(journalPath, entry) {
|
|
4047
|
+
fs__default.default.mkdirSync(path__default.default.dirname(journalPath), { recursive: true, mode: 448 });
|
|
4048
|
+
const fd = fs__default.default.openSync(journalPath, APPEND_FLAGS, 384);
|
|
4049
|
+
try {
|
|
4050
|
+
assertPrivateRegularFile(fd, journalPath);
|
|
4051
|
+
fs__default.default.writeSync(fd, serializeEntry(entry));
|
|
4052
|
+
} finally {
|
|
4053
|
+
fs__default.default.closeSync(fd);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
function assertPrivateRegularFile(fd, journalPath) {
|
|
4057
|
+
const st = fs__default.default.fstatSync(fd);
|
|
4058
|
+
if (!st.isFile()) {
|
|
4059
|
+
throw new Error(`pending-unsubscribe journal is not a regular file: ${journalPath}`);
|
|
4060
|
+
}
|
|
4061
|
+
if (st.nlink > 1) {
|
|
4062
|
+
throw new Error(`pending-unsubscribe journal has ${st.nlink} hard links: ${journalPath}`);
|
|
4063
|
+
}
|
|
4064
|
+
if ((st.mode & 63) !== 0) fs__default.default.fchmodSync(fd, 384);
|
|
4065
|
+
}
|
|
4066
|
+
function serializeEntry(entry) {
|
|
4067
|
+
return JSON.stringify(entry) + "\n";
|
|
4068
|
+
}
|
|
4069
|
+
function claimToken() {
|
|
4070
|
+
return `${Date.now().toString(36)}-${crypto2.randomBytes(6).toString("hex")}`;
|
|
4071
|
+
}
|
|
4072
|
+
function claimJournal(journalPath) {
|
|
4073
|
+
const claimed = `${journalPath}${DRAINING_INFIX}${claimToken()}`;
|
|
4074
|
+
try {
|
|
4075
|
+
fs__default.default.renameSync(journalPath, claimed);
|
|
4076
|
+
} catch (err) {
|
|
4077
|
+
if (isMissing(err)) return null;
|
|
4078
|
+
throw err;
|
|
4079
|
+
}
|
|
4080
|
+
markClaimActive(claimed);
|
|
4081
|
+
return claimed;
|
|
4082
|
+
}
|
|
4083
|
+
function markClaimActive(claimPath) {
|
|
4084
|
+
const now = /* @__PURE__ */ new Date();
|
|
4085
|
+
try {
|
|
4086
|
+
fs__default.default.utimesSync(claimPath, now, now);
|
|
4087
|
+
} catch (err) {
|
|
4088
|
+
if (!isMissing(err)) throw err;
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
function adoptAbandonedClaims(journalPath, opts = {}) {
|
|
4092
|
+
const olderThanMs = opts.olderThanMs ?? ADOPT_AFTER_MS;
|
|
4093
|
+
const now = opts.now ?? Date.now();
|
|
4094
|
+
const dir = path__default.default.dirname(journalPath);
|
|
4095
|
+
const prefix = path__default.default.basename(journalPath) + DRAINING_INFIX;
|
|
4096
|
+
let names;
|
|
4097
|
+
try {
|
|
4098
|
+
names = fs__default.default.readdirSync(dir);
|
|
4099
|
+
} catch (err) {
|
|
4100
|
+
if (isMissing(err)) return [];
|
|
4101
|
+
throw err;
|
|
4102
|
+
}
|
|
4103
|
+
const adopted = [];
|
|
4104
|
+
for (const name of names) {
|
|
4105
|
+
if (!name.startsWith(prefix)) continue;
|
|
4106
|
+
const full = path__default.default.join(dir, name);
|
|
4107
|
+
let st;
|
|
4108
|
+
try {
|
|
4109
|
+
st = fs__default.default.statSync(full);
|
|
4110
|
+
} catch (err) {
|
|
4111
|
+
if (isMissing(err)) continue;
|
|
4112
|
+
throw err;
|
|
4113
|
+
}
|
|
4114
|
+
if (!st.isFile()) continue;
|
|
4115
|
+
if (now - st.mtimeMs < olderThanMs) continue;
|
|
4116
|
+
const mine = `${journalPath}${DRAINING_INFIX}${claimToken()}`;
|
|
4117
|
+
try {
|
|
4118
|
+
fs__default.default.renameSync(full, mine);
|
|
4119
|
+
markClaimActive(mine);
|
|
4120
|
+
adopted.push(mine);
|
|
4121
|
+
} catch (err) {
|
|
4122
|
+
if (!isMissing(err)) throw err;
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
return adopted;
|
|
4126
|
+
}
|
|
4127
|
+
function readClaim(claimPath) {
|
|
4128
|
+
let raw;
|
|
4129
|
+
try {
|
|
4130
|
+
raw = fs__default.default.readFileSync(claimPath, "utf8");
|
|
4131
|
+
} catch (err) {
|
|
4132
|
+
if (isMissing(err)) return { entries: [], malformed: [], bytes: 0 };
|
|
4133
|
+
throw err;
|
|
4134
|
+
}
|
|
4135
|
+
const entries = [];
|
|
4136
|
+
const malformed = [];
|
|
4137
|
+
const lines = raw.split("\n");
|
|
4138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4139
|
+
const line = lines[i];
|
|
4140
|
+
if (line.trim() === "") continue;
|
|
4141
|
+
const parsed = parseEntry(line);
|
|
4142
|
+
if (parsed) entries.push(parsed);
|
|
4143
|
+
else malformed.push({ lineNumber: i + 1, preview: line.slice(0, 120) });
|
|
4144
|
+
}
|
|
4145
|
+
return { entries, malformed, bytes: Buffer.byteLength(raw) };
|
|
4146
|
+
}
|
|
4147
|
+
function parseEntry(line) {
|
|
4148
|
+
let obj;
|
|
4149
|
+
try {
|
|
4150
|
+
obj = JSON.parse(line);
|
|
4151
|
+
} catch {
|
|
4152
|
+
return null;
|
|
4153
|
+
}
|
|
4154
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
|
|
4155
|
+
const o = obj;
|
|
4156
|
+
const check = unsubscribeInputSchema.safeParse({
|
|
4157
|
+
email: o.email,
|
|
4158
|
+
scope: o.scope,
|
|
4159
|
+
reason: "user_request",
|
|
4160
|
+
source: "one-click"
|
|
4161
|
+
});
|
|
4162
|
+
if (!check.success) return null;
|
|
4163
|
+
const at = typeof o.at === "number" && Number.isFinite(o.at) ? o.at : Date.now();
|
|
4164
|
+
const attempts = typeof o.attempts === "number" && Number.isFinite(o.attempts) && o.attempts > 0 ? Math.floor(o.attempts) : void 0;
|
|
4165
|
+
return {
|
|
4166
|
+
email: check.data.email,
|
|
4167
|
+
scope: check.data.scope,
|
|
4168
|
+
at,
|
|
4169
|
+
...attempts === void 0 ? {} : { attempts }
|
|
4170
|
+
};
|
|
4171
|
+
}
|
|
4172
|
+
function entryToInput(entry) {
|
|
4173
|
+
return unsubscribeInputSchema.parse({
|
|
4174
|
+
email: entry.email,
|
|
4175
|
+
scope: entry.scope,
|
|
4176
|
+
reason: "user_request",
|
|
4177
|
+
// Distinguishable in `mailer_suppressions.source` from a live one-click,
|
|
4178
|
+
// but only on rows the drain actually created (the upsert is
|
|
4179
|
+
// `$setOnInsert`, so a row the live path already wrote keeps its own).
|
|
4180
|
+
source: "one-click-drain"
|
|
4181
|
+
});
|
|
4182
|
+
}
|
|
4183
|
+
function releaseClaim(journalPath, claimPath, remaining) {
|
|
4184
|
+
if (remaining.length > 0) {
|
|
4185
|
+
fs__default.default.mkdirSync(path__default.default.dirname(journalPath), { recursive: true, mode: 448 });
|
|
4186
|
+
const fd = fs__default.default.openSync(journalPath, APPEND_FLAGS, 384);
|
|
4187
|
+
try {
|
|
4188
|
+
assertPrivateRegularFile(fd, journalPath);
|
|
4189
|
+
fs__default.default.writeSync(
|
|
4190
|
+
fd,
|
|
4191
|
+
remaining.map((e) => serializeEntry({ ...e, attempts: (e.attempts ?? 0) + 1 })).join("")
|
|
4192
|
+
);
|
|
4193
|
+
} finally {
|
|
4194
|
+
fs__default.default.closeSync(fd);
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
try {
|
|
4198
|
+
fs__default.default.unlinkSync(claimPath);
|
|
4199
|
+
} catch (err) {
|
|
4200
|
+
if (!isMissing(err)) throw err;
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
function journalExists(journalPath) {
|
|
4204
|
+
try {
|
|
4205
|
+
return fs__default.default.statSync(journalPath).isFile();
|
|
4206
|
+
} catch (err) {
|
|
4207
|
+
if (isMissing(err)) return false;
|
|
4208
|
+
throw err;
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
function isMissing(err) {
|
|
4212
|
+
const code = err?.code;
|
|
4213
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
// src/server/runner/pending-unsubs.ts
|
|
4217
|
+
var MAX_ENTRIES_PER_PASS = 1e3;
|
|
4218
|
+
var NOISY_AFTER_ATTEMPTS = 10;
|
|
4219
|
+
var consoleLog = {
|
|
4220
|
+
warn: (fields, msg) => console.warn(msg ?? "mailery", fields),
|
|
4221
|
+
error: (fields, msg) => console.error(msg ?? "mailery", fields)
|
|
4222
|
+
};
|
|
4223
|
+
async function drainPendingUnsubscribes(ctx, opts = {}) {
|
|
4224
|
+
const journalPath = opts.path ?? ctx.config.pendingUnsubsPath;
|
|
4225
|
+
const result = {
|
|
4226
|
+
enabled: Boolean(journalPath),
|
|
4227
|
+
applied: 0,
|
|
4228
|
+
deferred: 0,
|
|
4229
|
+
malformed: 0,
|
|
4230
|
+
batches: 0
|
|
4231
|
+
};
|
|
4232
|
+
if (!journalPath) return result;
|
|
4233
|
+
const log = opts.log ?? consoleLog;
|
|
4234
|
+
const budget = opts.maxEntries ?? MAX_ENTRIES_PER_PASS;
|
|
4235
|
+
const claims = [];
|
|
4236
|
+
try {
|
|
4237
|
+
claims.push(...adoptAbandonedClaims(journalPath));
|
|
4238
|
+
} catch (err) {
|
|
4239
|
+
log.error?.({ err, path: journalPath }, "mailery: pending-unsub claim adoption failed");
|
|
4240
|
+
}
|
|
4241
|
+
if (journalExists(journalPath)) {
|
|
4242
|
+
const claimed = claimJournal(journalPath);
|
|
4243
|
+
if (claimed) claims.push(claimed);
|
|
4244
|
+
}
|
|
4245
|
+
if (claims.length === 0) return result;
|
|
4246
|
+
let remainingBudget = budget;
|
|
4247
|
+
for (const claimPath of claims) {
|
|
4248
|
+
result.batches++;
|
|
4249
|
+
const batch = readClaim(claimPath);
|
|
4250
|
+
if (batch.bytes > JOURNAL_WARN_BYTES) {
|
|
4251
|
+
log.warn?.(
|
|
4252
|
+
{ path: claimPath, bytes: batch.bytes, entries: batch.entries.length },
|
|
4253
|
+
"mailery: pending-unsubscribe journal is large \u2014 Mongo has been unreachable for a while"
|
|
4254
|
+
);
|
|
4255
|
+
}
|
|
4256
|
+
for (const bad of batch.malformed) {
|
|
4257
|
+
result.malformed++;
|
|
4258
|
+
log.warn?.(
|
|
4259
|
+
{ path: claimPath, lineNumber: bad.lineNumber, preview: bad.preview },
|
|
4260
|
+
"mailery: pending-unsubscribe journal line unusable \u2014 skipped"
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
4263
|
+
const deferred = [];
|
|
4264
|
+
let stop = false;
|
|
4265
|
+
for (let i = 0; i < batch.entries.length; i++) {
|
|
4266
|
+
const entry = batch.entries[i];
|
|
4267
|
+
if (stop || remainingBudget <= 0) {
|
|
4268
|
+
deferred.push(entry);
|
|
4269
|
+
continue;
|
|
4270
|
+
}
|
|
4271
|
+
try {
|
|
4272
|
+
await applyUnsubscribe(ctx.collections, entryToInput(entry));
|
|
4273
|
+
result.applied++;
|
|
4274
|
+
remainingBudget--;
|
|
4275
|
+
} catch (err) {
|
|
4276
|
+
stop = true;
|
|
4277
|
+
deferred.push(entry);
|
|
4278
|
+
const attempts = (entry.attempts ?? 0) + 1;
|
|
4279
|
+
const fields = { err, path: claimPath, attempts, pending: batch.entries.length - i };
|
|
4280
|
+
if (attempts >= NOISY_AFTER_ATTEMPTS) {
|
|
4281
|
+
log.error?.(
|
|
4282
|
+
fields,
|
|
4283
|
+
"mailery: pending unsubscribe still not applied after repeated attempts \u2014 investigate"
|
|
4284
|
+
);
|
|
4285
|
+
} else {
|
|
4286
|
+
log.warn?.(fields, "mailery: pending-unsubscribe replay failed \u2014 deferred to next tick");
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
result.deferred += deferred.length;
|
|
4291
|
+
releaseClaim(journalPath, claimPath, deferred);
|
|
4292
|
+
}
|
|
4293
|
+
return result;
|
|
4294
|
+
}
|
|
3896
4295
|
|
|
3897
4296
|
// src/server/runner/tick.ts
|
|
3898
4297
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
@@ -3934,6 +4333,9 @@ async function runTick(ctx) {
|
|
|
3934
4333
|
await drainOutbox(ctx).catch((err) => {
|
|
3935
4334
|
console.error("mailery: outbox drain failed", err);
|
|
3936
4335
|
});
|
|
4336
|
+
await drainPendingUnsubscribes(ctx).catch((err) => {
|
|
4337
|
+
console.error("mailery: pending-unsubscribe drain failed", err);
|
|
4338
|
+
});
|
|
3937
4339
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3938
4340
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3939
4341
|
});
|
|
@@ -4066,6 +4468,10 @@ var Mailer = class _Mailer {
|
|
|
4066
4468
|
* MAILER_FROM_NAME / MAILER_FROM_EMAIL
|
|
4067
4469
|
* MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
|
|
4068
4470
|
* MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
|
|
4471
|
+
* MAILER_SENDGRID_WEBHOOK_TOLERANCE — signed-webhook replay window, seconds
|
|
4472
|
+
* (default 300; '0' disables the check).
|
|
4473
|
+
* Unparseable values fall back to the
|
|
4474
|
+
* default rather than disabling it.
|
|
4069
4475
|
* MAILER_HOST_USERS_COLLECTION (default 'users')
|
|
4070
4476
|
* MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
|
|
4071
4477
|
* MAILER_HOST_USERS_ID_FIELD (default '_id')
|
|
@@ -4100,6 +4506,10 @@ var Mailer = class _Mailer {
|
|
|
4100
4506
|
providers.sendgrid = new SendGridProvider2({
|
|
4101
4507
|
apiKey: env.MAILER_SENDGRID_API_KEY,
|
|
4102
4508
|
webhookVerificationKey: env.MAILER_SENDGRID_WEBHOOK_KEY,
|
|
4509
|
+
// Left undefined when unset/blank so the provider default (300s) applies.
|
|
4510
|
+
// resolveWebhookToleranceSeconds() rejects NaN back to the default, so a
|
|
4511
|
+
// typo here can never silently switch the replay check off.
|
|
4512
|
+
webhookToleranceSeconds: env.MAILER_SENDGRID_WEBHOOK_TOLERANCE ? Number(env.MAILER_SENDGRID_WEBHOOK_TOLERANCE) : void 0,
|
|
4103
4513
|
sandbox: env.NODE_ENV !== "production"
|
|
4104
4514
|
});
|
|
4105
4515
|
}
|
|
@@ -4127,8 +4537,15 @@ var Mailer = class _Mailer {
|
|
|
4127
4537
|
}
|
|
4128
4538
|
static async init(input) {
|
|
4129
4539
|
const config = resolveConfig(input);
|
|
4130
|
-
if (!config.providers
|
|
4131
|
-
throw new Error(
|
|
4540
|
+
if (!resolveProvider(config.providers, config.defaultProvider)) {
|
|
4541
|
+
throw new Error(
|
|
4542
|
+
`defaultProvider "${config.defaultProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4543
|
+
);
|
|
4544
|
+
}
|
|
4545
|
+
if (config.defaultTransactionalProvider != null && !resolveProvider(config.providers, config.defaultTransactionalProvider)) {
|
|
4546
|
+
throw new Error(
|
|
4547
|
+
`defaultTransactionalProvider "${config.defaultTransactionalProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4548
|
+
);
|
|
4132
4549
|
}
|
|
4133
4550
|
if (config.varsAdapter) {
|
|
4134
4551
|
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
@@ -4270,38 +4687,14 @@ var Mailer = class _Mailer {
|
|
|
4270
4687
|
}
|
|
4271
4688
|
}
|
|
4272
4689
|
}
|
|
4690
|
+
/**
|
|
4691
|
+
* The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
|
|
4692
|
+
* drain (INVARIANT 8) replays a journaled opt-out through exactly this path
|
|
4693
|
+
* without holding a `Mailer` instance.
|
|
4694
|
+
*/
|
|
4273
4695
|
async unsubscribe(email, opts) {
|
|
4274
4696
|
const parsed = unsubscribeInputSchema.parse({ email, ...opts });
|
|
4275
|
-
|
|
4276
|
-
const now = /* @__PURE__ */ new Date();
|
|
4277
|
-
await this.collections.suppressions.updateOne(
|
|
4278
|
-
{ email: normalized, scope: parsed.scope },
|
|
4279
|
-
{
|
|
4280
|
-
$setOnInsert: {
|
|
4281
|
-
email: normalized,
|
|
4282
|
-
emailHash: sha256Hex(normalized),
|
|
4283
|
-
scope: parsed.scope,
|
|
4284
|
-
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
4285
|
-
reason: "unsubscribed",
|
|
4286
|
-
source: parsed.source,
|
|
4287
|
-
notes: parsed.notes ?? null,
|
|
4288
|
-
addedAt: now,
|
|
4289
|
-
expiresAt: null
|
|
4290
|
-
}
|
|
4291
|
-
},
|
|
4292
|
-
{ upsert: true }
|
|
4293
|
-
);
|
|
4294
|
-
await this.collections.subscriptions.updateOne(
|
|
4295
|
-
{ emailAtSubscribe: normalized },
|
|
4296
|
-
{
|
|
4297
|
-
$set: {
|
|
4298
|
-
status: "unsubscribed",
|
|
4299
|
-
unsubscribedAt: now,
|
|
4300
|
-
unsubscribeReason: parsed.reason,
|
|
4301
|
-
updatedAt: now
|
|
4302
|
-
}
|
|
4303
|
-
}
|
|
4304
|
-
);
|
|
4697
|
+
await applyUnsubscribe(this.collections, parsed);
|
|
4305
4698
|
}
|
|
4306
4699
|
async suppress(email, opts) {
|
|
4307
4700
|
const parsed = suppressInputSchema.parse({ email, ...opts });
|
|
@@ -4637,8 +5030,20 @@ var NullProvider = class {
|
|
|
4637
5030
|
status: "accepted"
|
|
4638
5031
|
};
|
|
4639
5032
|
}
|
|
5033
|
+
/**
|
|
5034
|
+
* Fails closed. This provider has no signing key, so it can never establish
|
|
5035
|
+
* that an inbound payload is authentic — and a method whose entire job is to
|
|
5036
|
+
* reject unauthenticated input must not answer "yes" by default. Returning
|
|
5037
|
+
* `true` here would mean any unsigned body posted to the webhook route is
|
|
5038
|
+
* accepted as genuine whenever the null provider is registered, which is
|
|
5039
|
+
* exactly the dev/staging configuration where that is easiest to miss.
|
|
5040
|
+
*
|
|
5041
|
+
* Nothing is lost: `parseWebhookEvents()` returns `[]`, so there was never
|
|
5042
|
+
* any inbound behaviour to preserve. To exercise the inbound half locally,
|
|
5043
|
+
* use a provider that actually signs (see `test/README.md`).
|
|
5044
|
+
*/
|
|
4640
5045
|
async verifyWebhook() {
|
|
4641
|
-
return
|
|
5046
|
+
return false;
|
|
4642
5047
|
}
|
|
4643
5048
|
parseWebhookEvents() {
|
|
4644
5049
|
return [];
|
|
@@ -5775,13 +6180,13 @@ function createAdminRouter(mailer, opts = {}) {
|
|
|
5775
6180
|
req.actor = getActor(req);
|
|
5776
6181
|
next();
|
|
5777
6182
|
});
|
|
5778
|
-
router.use("/api",
|
|
6183
|
+
router.use("/api", createAdminApiRouter(mailer, opts));
|
|
5779
6184
|
router.get(/.*/, (_req, res) => {
|
|
5780
6185
|
res.sendFile(path__default.default.join(spaDir, "index.html"));
|
|
5781
6186
|
});
|
|
5782
6187
|
return router;
|
|
5783
6188
|
}
|
|
5784
|
-
function
|
|
6189
|
+
function createAdminApiRouter(mailer, opts = {}) {
|
|
5785
6190
|
const r = express.Router();
|
|
5786
6191
|
const c = mailer.collections;
|
|
5787
6192
|
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
@@ -5978,7 +6383,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
5978
6383
|
asyncHandler(async (req, res) => {
|
|
5979
6384
|
const before = await c.flows.findOne({ slug: req.params.slug });
|
|
5980
6385
|
if (!before) return res.status(404).json({ error: "not_found" });
|
|
5981
|
-
|
|
6386
|
+
const now = /* @__PURE__ */ new Date();
|
|
6387
|
+
await c.flows.updateOne(
|
|
6388
|
+
{ _id: before._id },
|
|
6389
|
+
{ $set: { enabled: true, updatedAt: now, ...before.lastTriggerScanAt ? {} : { lastTriggerScanAt: now } } }
|
|
6390
|
+
);
|
|
5982
6391
|
await mailer.audit({
|
|
5983
6392
|
actor: req.actor,
|
|
5984
6393
|
action: "flow.resume",
|
|
@@ -6559,6 +6968,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6559
6968
|
if (!Array.isArray(draftSteps) || draftSteps.length === 0) {
|
|
6560
6969
|
return res.status(400).json({ error: "empty_flow", message: "flow has no steps to publish" });
|
|
6561
6970
|
}
|
|
6971
|
+
const enable = req.body?.enable !== false;
|
|
6562
6972
|
const nextVersion = (flow.version ?? 0) + 1;
|
|
6563
6973
|
const now = /* @__PURE__ */ new Date();
|
|
6564
6974
|
await c.flowVersions.insertOne({
|
|
@@ -6575,11 +6985,14 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6575
6985
|
$set: {
|
|
6576
6986
|
steps: draftSteps,
|
|
6577
6987
|
version: nextVersion,
|
|
6578
|
-
enabled: true,
|
|
6988
|
+
enabled: enable ? true : flow.enabled,
|
|
6579
6989
|
draft: null,
|
|
6580
6990
|
publishedAt: now,
|
|
6581
6991
|
publishedBy: req.actor,
|
|
6582
|
-
updatedAt: now
|
|
6992
|
+
updatedAt: now,
|
|
6993
|
+
// First enable: stamp the trigger watermark so the scan does not
|
|
6994
|
+
// replay every event since the flow document was created.
|
|
6995
|
+
...enable && !flow.lastTriggerScanAt ? { lastTriggerScanAt: now } : {}
|
|
6583
6996
|
}
|
|
6584
6997
|
}
|
|
6585
6998
|
);
|
|
@@ -6587,9 +7000,9 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6587
7000
|
actor: req.actor,
|
|
6588
7001
|
action: "flow.publish",
|
|
6589
7002
|
resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
|
|
6590
|
-
diffSummary: `Published v${nextVersion}`
|
|
7003
|
+
diffSummary: `Published v${nextVersion}${enable ? "" : " (left disabled)"}`
|
|
6591
7004
|
});
|
|
6592
|
-
return res.json({ ok: true, version: nextVersion });
|
|
7005
|
+
return res.json({ ok: true, version: nextVersion, enabled: enable ? true : flow.enabled });
|
|
6593
7006
|
})
|
|
6594
7007
|
);
|
|
6595
7008
|
r.delete(
|
|
@@ -6780,11 +7193,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6780
7193
|
if (cached) {
|
|
6781
7194
|
return res.json({ cached: true, status: "ready", score: cached });
|
|
6782
7195
|
}
|
|
6783
|
-
const
|
|
6784
|
-
const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
|
|
7196
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
6785
7197
|
if (!provider) {
|
|
6786
|
-
return res.status(
|
|
7198
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
6787
7199
|
}
|
|
7200
|
+
const { checkId, emailAddress } = await client.provisionCheck();
|
|
6788
7201
|
try {
|
|
6789
7202
|
await provider.send({
|
|
6790
7203
|
to: emailAddress,
|
|
@@ -7133,7 +7546,10 @@ function apiRouter(mailer, opts = {}) {
|
|
|
7133
7546
|
trackOpens: false,
|
|
7134
7547
|
trackClicks: false
|
|
7135
7548
|
});
|
|
7136
|
-
const provider = mailer.providers
|
|
7549
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
7550
|
+
if (!provider) {
|
|
7551
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
7552
|
+
}
|
|
7137
7553
|
const result = await provider.send({
|
|
7138
7554
|
to,
|
|
7139
7555
|
fromName: rendered.fromName,
|
|
@@ -7285,6 +7701,18 @@ function asyncHandler(fn) {
|
|
|
7285
7701
|
fn(req, res, next).catch(next);
|
|
7286
7702
|
};
|
|
7287
7703
|
}
|
|
7704
|
+
function unknownProviderError(mailer, tpl) {
|
|
7705
|
+
const override = tpl.providerOverride ?? null;
|
|
7706
|
+
const name = override ?? mailer.config.defaultProvider;
|
|
7707
|
+
const known = registeredProviderNames(mailer.providers);
|
|
7708
|
+
const source = override ? `template "${tpl.slug ?? "?"}" overrides the provider to "${name}"` : `the configured defaultProvider is "${name}"`;
|
|
7709
|
+
return {
|
|
7710
|
+
error: "provider_unknown",
|
|
7711
|
+
message: `${source}, but no such provider is registered. Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
|
|
7712
|
+
provider: name,
|
|
7713
|
+
registeredProviders: known
|
|
7714
|
+
};
|
|
7715
|
+
}
|
|
7288
7716
|
async function collectQueueCounts(mailer) {
|
|
7289
7717
|
const qs = Object.values(mailer.queues);
|
|
7290
7718
|
const sum = async (key) => {
|
|
@@ -7420,60 +7848,1666 @@ async function computeBroadcastStats(mailer, idFilter) {
|
|
|
7420
7848
|
}
|
|
7421
7849
|
return out;
|
|
7422
7850
|
}
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
)
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
);
|
|
7439
|
-
router.use("/unsub", express__default.default.urlencoded({ extended: false }));
|
|
7440
|
-
router.get("/open/:sendId.png", async (req, res) => {
|
|
7441
|
-
res.setHeader("Content-Type", "image/png");
|
|
7442
|
-
res.setHeader("Content-Length", String(PIXEL.length));
|
|
7443
|
-
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
7444
|
-
res.status(200).end(PIXEL);
|
|
7445
|
-
const id = req.params.sendId;
|
|
7446
|
-
if (!mongodb.ObjectId.isValid(id)) return;
|
|
7447
|
-
const sendId = new mongodb.ObjectId(id);
|
|
7851
|
+
|
|
7852
|
+
// src/server/api/wrap.ts
|
|
7853
|
+
var consoleRouteLogger = {
|
|
7854
|
+
error(fields, msg) {
|
|
7855
|
+
console.error(msg ?? "mailery: request failed", fields);
|
|
7856
|
+
},
|
|
7857
|
+
warn(fields, msg) {
|
|
7858
|
+
console.warn(msg ?? "mailery: request warning", fields);
|
|
7859
|
+
},
|
|
7860
|
+
info(fields, msg) {
|
|
7861
|
+
console.info(msg ?? "mailery: request", fields);
|
|
7862
|
+
}
|
|
7863
|
+
};
|
|
7864
|
+
function wrap(logger, handler) {
|
|
7865
|
+
return async (req, res, next) => {
|
|
7448
7866
|
try {
|
|
7449
|
-
|
|
7450
|
-
if (!send) return;
|
|
7451
|
-
await mailer.collections.sends.updateOne(
|
|
7452
|
-
{ _id: sendId },
|
|
7453
|
-
{
|
|
7454
|
-
$set: {
|
|
7455
|
-
openedAt: send.openedAt ?? /* @__PURE__ */ new Date(),
|
|
7456
|
-
status: "delivered"
|
|
7457
|
-
},
|
|
7458
|
-
$inc: { openCount: 1 }
|
|
7459
|
-
}
|
|
7460
|
-
);
|
|
7867
|
+
await handler(req, res, next);
|
|
7461
7868
|
} catch (err) {
|
|
7462
|
-
|
|
7869
|
+
logger.error?.({ err, path: req.originalUrl }, "mailery: request failed");
|
|
7870
|
+
if (res.headersSent) return;
|
|
7871
|
+
res.status(500).json({ error: "internal_error" });
|
|
7463
7872
|
}
|
|
7873
|
+
};
|
|
7874
|
+
}
|
|
7875
|
+
|
|
7876
|
+
// src/server/api/agent.ts
|
|
7877
|
+
init_vars();
|
|
7878
|
+
|
|
7879
|
+
// src/server/runner/arm.ts
|
|
7880
|
+
var FlowOperationError = class extends Error {
|
|
7881
|
+
constructor(code, message, status = 400) {
|
|
7882
|
+
super(message);
|
|
7883
|
+
this.code = code;
|
|
7884
|
+
this.status = status;
|
|
7885
|
+
this.name = "FlowOperationError";
|
|
7886
|
+
}
|
|
7887
|
+
code;
|
|
7888
|
+
status;
|
|
7889
|
+
};
|
|
7890
|
+
async function stampWatermarkIfNull(collections, flow, now = /* @__PURE__ */ new Date()) {
|
|
7891
|
+
if (flow.lastTriggerScanAt) return flow.lastTriggerScanAt;
|
|
7892
|
+
await collections.flows.updateOne(
|
|
7893
|
+
{ _id: flow._id, lastTriggerScanAt: null },
|
|
7894
|
+
{ $set: { lastTriggerScanAt: now, updatedAt: now } }
|
|
7895
|
+
);
|
|
7896
|
+
return now;
|
|
7897
|
+
}
|
|
7898
|
+
async function armFlow(mailer, slug, opts) {
|
|
7899
|
+
const c = mailer.collections;
|
|
7900
|
+
const flow = await c.flows.findOne({ slug });
|
|
7901
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
7902
|
+
if (!Array.isArray(flow.steps) || flow.steps.length === 0) {
|
|
7903
|
+
throw new FlowOperationError(
|
|
7904
|
+
"no_live_steps",
|
|
7905
|
+
`flow "${slug}" (v${flow.version}) has no published steps \u2014 publish it first; an empty flow completes every run instantly`,
|
|
7906
|
+
409
|
|
7907
|
+
);
|
|
7908
|
+
}
|
|
7909
|
+
const eventName = flow.trigger?.eventName ?? null;
|
|
7910
|
+
const now = /* @__PURE__ */ new Date();
|
|
7911
|
+
const watermark = opts.since ?? now;
|
|
7912
|
+
if (flow.enabled) {
|
|
7913
|
+
return {
|
|
7914
|
+
slug,
|
|
7915
|
+
version: flow.version,
|
|
7916
|
+
armed: false,
|
|
7917
|
+
alreadyEnabled: true,
|
|
7918
|
+
watermark: flow.lastTriggerScanAt ?? null,
|
|
7919
|
+
eventName,
|
|
7920
|
+
skippedEvents: 0,
|
|
7921
|
+
pendingEvents: 0
|
|
7922
|
+
};
|
|
7923
|
+
}
|
|
7924
|
+
const previous = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
7925
|
+
const overlapFrom = new Date(watermark.getTime() - SCAN_OVERLAP_MS);
|
|
7926
|
+
const [skippedEvents, pendingEvents] = eventName ? await Promise.all([
|
|
7927
|
+
c.events.countDocuments({ name: eventName, createdAt: { $gt: previous, $lte: overlapFrom } }),
|
|
7928
|
+
c.events.countDocuments({ name: eventName, createdAt: { $gt: overlapFrom } })
|
|
7929
|
+
]) : [0, 0];
|
|
7930
|
+
const res = await c.flows.updateOne(
|
|
7931
|
+
{ _id: flow._id, enabled: false },
|
|
7932
|
+
{ $set: { enabled: true, lastTriggerScanAt: watermark, updatedAt: now } }
|
|
7933
|
+
);
|
|
7934
|
+
if (res.modifiedCount === 0) {
|
|
7935
|
+
const again = await c.flows.findOne({ _id: flow._id });
|
|
7936
|
+
return {
|
|
7937
|
+
slug,
|
|
7938
|
+
version: flow.version,
|
|
7939
|
+
armed: false,
|
|
7940
|
+
alreadyEnabled: !!again?.enabled,
|
|
7941
|
+
watermark: again?.lastTriggerScanAt ?? null,
|
|
7942
|
+
eventName,
|
|
7943
|
+
skippedEvents: 0,
|
|
7944
|
+
pendingEvents: 0
|
|
7945
|
+
};
|
|
7946
|
+
}
|
|
7947
|
+
await mailer.audit({
|
|
7948
|
+
actor: opts.actor,
|
|
7949
|
+
action: "flow.arm",
|
|
7950
|
+
resource: { collection: "mailer_flows", id: flow._id, slug },
|
|
7951
|
+
diffSummary: `Enabled v${flow.version} with lastTriggerScanAt=${watermark.toISOString()} (skipped ${skippedEvents} earlier ${eventName ?? "trigger"} event(s), ${pendingEvents} pending)`
|
|
7464
7952
|
});
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7953
|
+
return {
|
|
7954
|
+
slug,
|
|
7955
|
+
version: flow.version,
|
|
7956
|
+
armed: true,
|
|
7957
|
+
alreadyEnabled: false,
|
|
7958
|
+
watermark,
|
|
7959
|
+
eventName,
|
|
7960
|
+
skippedEvents,
|
|
7961
|
+
pendingEvents
|
|
7962
|
+
};
|
|
7963
|
+
}
|
|
7964
|
+
async function disarmFlow(mailer, slug, actor) {
|
|
7965
|
+
const c = mailer.collections;
|
|
7966
|
+
const flow = await c.flows.findOne({ slug });
|
|
7967
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
7968
|
+
if (!flow.enabled) return { slug, disarmed: false };
|
|
7969
|
+
await c.flows.updateOne({ _id: flow._id }, { $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } });
|
|
7970
|
+
await mailer.audit({
|
|
7971
|
+
actor,
|
|
7972
|
+
action: "flow.pause",
|
|
7973
|
+
resource: { collection: "mailer_flows", id: flow._id, slug },
|
|
7974
|
+
diffSummary: "Disarmed (enabled: false); in-flight runs continue"
|
|
7975
|
+
});
|
|
7976
|
+
return { slug, disarmed: true };
|
|
7977
|
+
}
|
|
7978
|
+
function isCanaryGate(step) {
|
|
7979
|
+
const s = step;
|
|
7980
|
+
return !!s && s.type === "condition" && s.canaryGate === true;
|
|
7981
|
+
}
|
|
7982
|
+
function gateStep(tag) {
|
|
7983
|
+
return { type: "condition", test: { hasTag: tag }, ifFalse: "exit", canaryGate: true };
|
|
7984
|
+
}
|
|
7985
|
+
async function publishVersion(mailer, flow, steps, actor, summary) {
|
|
7986
|
+
const c = mailer.collections;
|
|
7987
|
+
const nextVersion = (flow.version ?? 0) + 1;
|
|
7988
|
+
const now = /* @__PURE__ */ new Date();
|
|
7989
|
+
await c.flowVersions.insertOne({
|
|
7990
|
+
flowId: flow._id,
|
|
7991
|
+
version: nextVersion,
|
|
7992
|
+
steps,
|
|
7993
|
+
trigger: flow.trigger,
|
|
7994
|
+
publishedAt: now,
|
|
7995
|
+
publishedBy: actor
|
|
7996
|
+
});
|
|
7997
|
+
await c.flows.updateOne(
|
|
7998
|
+
{ _id: flow._id },
|
|
7999
|
+
{ $set: { steps, version: nextVersion, draft: null, publishedAt: now, publishedBy: actor, updatedAt: now } }
|
|
8000
|
+
);
|
|
8001
|
+
await mailer.audit({
|
|
8002
|
+
actor,
|
|
8003
|
+
action: "flow.publish",
|
|
8004
|
+
resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
|
|
8005
|
+
diffSummary: `Published v${nextVersion}: ${summary}`
|
|
8006
|
+
});
|
|
8007
|
+
return { version: nextVersion };
|
|
8008
|
+
}
|
|
8009
|
+
async function gateFlow(mailer, slug, opts) {
|
|
8010
|
+
const tag = String(opts.tag ?? "").trim();
|
|
8011
|
+
if (!tag) throw new FlowOperationError("tag_required", "a canary tag is required");
|
|
8012
|
+
const flow = await mailer.collections.flows.findOne({ slug });
|
|
8013
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
8014
|
+
const live = Array.isArray(flow.steps) ? flow.steps : [];
|
|
8015
|
+
if (live.length === 0) {
|
|
8016
|
+
throw new FlowOperationError("no_live_steps", `flow "${slug}" has no published steps to gate`, 409);
|
|
8017
|
+
}
|
|
8018
|
+
if (isCanaryGate(live[0])) {
|
|
8019
|
+
throw new FlowOperationError(
|
|
8020
|
+
"already_gated",
|
|
8021
|
+
`flow "${slug}" v${flow.version} is already gated on "${live[0].test.hasTag}" \u2014 ungate first`,
|
|
8022
|
+
409
|
|
7472
8023
|
);
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
8024
|
+
}
|
|
8025
|
+
await mailer.collections.flowVersions.updateOne(
|
|
8026
|
+
{ flowId: flow._id, version: flow.version },
|
|
8027
|
+
{
|
|
8028
|
+
$setOnInsert: {
|
|
8029
|
+
flowId: flow._id,
|
|
8030
|
+
version: flow.version,
|
|
8031
|
+
steps: live,
|
|
8032
|
+
trigger: flow.trigger,
|
|
8033
|
+
publishedAt: flow.publishedAt ?? flow.updatedAt ?? /* @__PURE__ */ new Date(),
|
|
8034
|
+
publishedBy: flow.publishedBy ?? "unknown"
|
|
8035
|
+
}
|
|
8036
|
+
},
|
|
8037
|
+
{ upsert: true }
|
|
8038
|
+
);
|
|
8039
|
+
const { version } = await publishVersion(
|
|
8040
|
+
mailer,
|
|
8041
|
+
flow,
|
|
8042
|
+
[gateStep(tag), ...live],
|
|
8043
|
+
opts.actor,
|
|
8044
|
+
`canary gate on tag "${tag}"`
|
|
8045
|
+
);
|
|
8046
|
+
return { slug, version, tag, enabled: flow.enabled };
|
|
8047
|
+
}
|
|
8048
|
+
async function ungateFlow(mailer, slug, opts) {
|
|
8049
|
+
const c = mailer.collections;
|
|
8050
|
+
const flow = await c.flows.findOne({ slug });
|
|
8051
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
8052
|
+
const live = Array.isArray(flow.steps) ? flow.steps : [];
|
|
8053
|
+
if (!isCanaryGate(live[0])) {
|
|
8054
|
+
throw new FlowOperationError("not_gated", `flow "${slug}" v${flow.version} is not gated`, 409);
|
|
8055
|
+
}
|
|
8056
|
+
const versions = await c.flowVersions.find({ flowId: flow._id }).sort({ version: -1 }).toArray();
|
|
8057
|
+
const clean = versions.find((v) => Array.isArray(v.steps) && v.steps.length > 0 && !isCanaryGate(v.steps[0]));
|
|
8058
|
+
if (!clean) {
|
|
8059
|
+
throw new FlowOperationError(
|
|
8060
|
+
"no_ungated_version",
|
|
8061
|
+
`flow "${slug}" has no ungated version in mailer_flow_versions to restore`,
|
|
8062
|
+
409
|
|
8063
|
+
);
|
|
8064
|
+
}
|
|
8065
|
+
const { version } = await publishVersion(
|
|
8066
|
+
mailer,
|
|
8067
|
+
flow,
|
|
8068
|
+
clean.steps,
|
|
8069
|
+
opts.actor,
|
|
8070
|
+
`restored steps of v${clean.version} (canary gate removed)`
|
|
8071
|
+
);
|
|
8072
|
+
return { slug, version, restoredFrom: clean.version, enabled: flow.enabled };
|
|
8073
|
+
}
|
|
8074
|
+
var MAX_STEPS = 1e3;
|
|
8075
|
+
async function simulateFlow(flow, contact, ctx, opts = {}) {
|
|
8076
|
+
const enteredAt = opts.at ?? /* @__PURE__ */ new Date();
|
|
8077
|
+
const steps = opts.steps ?? flow.steps ?? [];
|
|
8078
|
+
const eventName = flow.trigger?.eventName ?? "simulated";
|
|
8079
|
+
const eventProperties = opts.eventProperties ?? {};
|
|
8080
|
+
const reasons = [];
|
|
8081
|
+
if (!flow.enabled) reasons.push("flow is disabled (enabled: false)");
|
|
8082
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: contact.externalId });
|
|
8083
|
+
if (!sub) reasons.push("contact has no subscription row \u2014 the trigger scan requires one");
|
|
8084
|
+
else if (sub.status !== "subscribed") reasons.push(`subscription status is "${sub.status}", not "subscribed"`);
|
|
8085
|
+
if (flow.trigger?.once) {
|
|
8086
|
+
const existing = await ctx.collections.flowRuns.findOne(
|
|
8087
|
+
{ externalId: contact.externalId, flowId: flow._id },
|
|
8088
|
+
{ projection: { _id: 1, status: 1 } }
|
|
8089
|
+
);
|
|
8090
|
+
if (existing) reasons.push(`trigger.once is true and the contact already has a run (${existing.status})`);
|
|
8091
|
+
}
|
|
8092
|
+
if (steps.length === 0) reasons.push("flow has no live steps \u2014 a run would complete immediately");
|
|
8093
|
+
const run = {
|
|
8094
|
+
_id: new mongodb.ObjectId(),
|
|
8095
|
+
externalId: contact.externalId,
|
|
8096
|
+
flowId: flow._id,
|
|
8097
|
+
flowSlug: flow.slug,
|
|
8098
|
+
flowVersion: flow.version,
|
|
8099
|
+
emailAtEntry: contact.email,
|
|
8100
|
+
triggerEvent: { name: eventName, properties: eventProperties, occurredAt: enteredAt },
|
|
8101
|
+
triggerDedupeKey: null,
|
|
8102
|
+
enteredAt,
|
|
8103
|
+
status: "active",
|
|
8104
|
+
currentStepIndex: 0,
|
|
8105
|
+
currentBranchPath: [],
|
|
8106
|
+
nextActionAt: enteredAt,
|
|
8107
|
+
attemptsForCurrentStep: 0,
|
|
8108
|
+
history: [],
|
|
8109
|
+
exitedAt: null,
|
|
8110
|
+
exitReason: null,
|
|
8111
|
+
createdAt: enteredAt,
|
|
8112
|
+
updatedAt: enteredAt
|
|
8113
|
+
};
|
|
8114
|
+
const path3 = [];
|
|
8115
|
+
const sends = [];
|
|
8116
|
+
let list = steps;
|
|
8117
|
+
let index = 0;
|
|
8118
|
+
let branchPath = [];
|
|
8119
|
+
let t = enteredAt;
|
|
8120
|
+
let terminal = null;
|
|
8121
|
+
const record = (type, outcome, detail) => {
|
|
8122
|
+
path3.push({ at: t, stepIndex: index, branchPath: [...branchPath], type, outcome, ...detail ? { detail } : {} });
|
|
8123
|
+
};
|
|
8124
|
+
const predicateCtx = () => ({
|
|
8125
|
+
contact,
|
|
8126
|
+
run: { ...run, currentStepIndex: index, currentBranchPath: branchPath, nextActionAt: t },
|
|
8127
|
+
collections: ctx.collections,
|
|
8128
|
+
now: t,
|
|
8129
|
+
botFilter: ctx.config.botFilter
|
|
8130
|
+
});
|
|
8131
|
+
for (let guard = 0; guard < MAX_STEPS && !terminal; guard += 1) {
|
|
8132
|
+
const step = list[index];
|
|
8133
|
+
if (!step) {
|
|
8134
|
+
terminal = { kind: "completed", reason: "sequence_complete", at: t };
|
|
8135
|
+
break;
|
|
8136
|
+
}
|
|
8137
|
+
switch (step.type) {
|
|
8138
|
+
case "wait": {
|
|
8139
|
+
const ms = unitToMs2(step.value, step.unit);
|
|
8140
|
+
record("wait", "waited", { value: step.value, unit: step.unit, until: new Date(t.getTime() + ms) });
|
|
8141
|
+
t = new Date(t.getTime() + ms);
|
|
8142
|
+
index += 1;
|
|
8143
|
+
break;
|
|
8144
|
+
}
|
|
8145
|
+
case "condition": {
|
|
8146
|
+
const result = await evaluatePredicate(step.test, predicateCtx());
|
|
8147
|
+
if (result) {
|
|
8148
|
+
record("condition", "passed", { test: step.test, result });
|
|
8149
|
+
index += 1;
|
|
8150
|
+
} else if (step.ifFalse === "continue") {
|
|
8151
|
+
record("condition", "skipped_next", { test: step.test, result });
|
|
8152
|
+
index += 2;
|
|
8153
|
+
} else {
|
|
8154
|
+
record("condition", "exited", { test: step.test, result });
|
|
8155
|
+
terminal = { kind: "exited", reason: "condition_false", at: t };
|
|
8156
|
+
}
|
|
8157
|
+
break;
|
|
8158
|
+
}
|
|
8159
|
+
case "branch": {
|
|
8160
|
+
const result = await evaluatePredicate(step.test, predicateCtx());
|
|
8161
|
+
record("branch", result ? "branch_true" : "branch_false", { test: step.test, result });
|
|
8162
|
+
branchPath = [...branchPath, index, result ? "true" : "false", 0];
|
|
8163
|
+
list = result ? step.ifTrueSteps : step.ifFalseSteps;
|
|
8164
|
+
index = 0;
|
|
8165
|
+
break;
|
|
8166
|
+
}
|
|
8167
|
+
case "send": {
|
|
8168
|
+
let at = t;
|
|
8169
|
+
if (step.delivery) {
|
|
8170
|
+
at = computeDeliveryTime(t, step.delivery, contact.timezone);
|
|
8171
|
+
}
|
|
8172
|
+
if (at.getTime() > t.getTime() + 3e4) {
|
|
8173
|
+
record("send", "send_deferred", { templateSlug: step.templateSlug, delivery: step.delivery, until: at });
|
|
8174
|
+
t = at;
|
|
8175
|
+
}
|
|
8176
|
+
record("send", "send", { templateSlug: step.templateSlug });
|
|
8177
|
+
sends.push({ templateSlug: step.templateSlug, at: t, stepIndex: index, branchPath: [...branchPath] });
|
|
8178
|
+
index += 1;
|
|
8179
|
+
break;
|
|
8180
|
+
}
|
|
8181
|
+
case "tag":
|
|
8182
|
+
record("tag", "tagged", { addTags: step.addTags ?? [], removeTags: step.removeTags ?? [] });
|
|
8183
|
+
index += 1;
|
|
8184
|
+
break;
|
|
8185
|
+
case "fire_event":
|
|
8186
|
+
record("fire_event", "event_fired", { eventName: step.eventName });
|
|
8187
|
+
index += 1;
|
|
8188
|
+
break;
|
|
8189
|
+
case "webhook":
|
|
8190
|
+
record("webhook", "webhook", { url: step.url, method: step.method ?? "POST", note: "not called in simulation" });
|
|
8191
|
+
index += 1;
|
|
8192
|
+
break;
|
|
8193
|
+
case "exit":
|
|
8194
|
+
record("exit", "exited", { reason: step.reason ?? "exit_step" });
|
|
8195
|
+
terminal = { kind: "exited", reason: step.reason ?? "exit_step", at: t };
|
|
8196
|
+
break;
|
|
8197
|
+
}
|
|
8198
|
+
}
|
|
8199
|
+
if (!terminal) terminal = { kind: "truncated", reason: `stopped after ${MAX_STEPS} steps`, at: t };
|
|
8200
|
+
return {
|
|
8201
|
+
flow: { slug: flow.slug, version: flow.version, enabled: flow.enabled },
|
|
8202
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8203
|
+
enteredAt,
|
|
8204
|
+
wouldEnter: { ok: reasons.length === 0, reasons },
|
|
8205
|
+
path: path3,
|
|
8206
|
+
sends,
|
|
8207
|
+
terminal,
|
|
8208
|
+
durationMs: terminal.at.getTime() - enteredAt.getTime()
|
|
8209
|
+
};
|
|
8210
|
+
}
|
|
8211
|
+
function unitToMs2(value, unit) {
|
|
8212
|
+
const m = 6e4;
|
|
8213
|
+
switch (unit) {
|
|
8214
|
+
case "minutes":
|
|
8215
|
+
return value * m;
|
|
8216
|
+
case "hours":
|
|
8217
|
+
return value * 60 * m;
|
|
8218
|
+
case "days":
|
|
8219
|
+
return value * 24 * 60 * m;
|
|
8220
|
+
case "weeks":
|
|
8221
|
+
return value * 7 * 24 * 60 * m;
|
|
8222
|
+
}
|
|
8223
|
+
}
|
|
8224
|
+
|
|
8225
|
+
// src/server/api/agent.ts
|
|
8226
|
+
var VERSION = "0.16.0" ;
|
|
8227
|
+
var MIN_AGENT_TOKEN_LENGTH = 24;
|
|
8228
|
+
function createAgentRouter(mailer, opts) {
|
|
8229
|
+
if (!opts || !Array.isArray(opts.tokens) || opts.tokens.length === 0) {
|
|
8230
|
+
throw new Error("createAgentRouter: at least one bearer token is required \u2014 the agent API is never open");
|
|
8231
|
+
}
|
|
8232
|
+
for (const t of opts.tokens) {
|
|
8233
|
+
if (typeof t?.token !== "string" || t.token.length < MIN_AGENT_TOKEN_LENGTH) {
|
|
8234
|
+
throw new Error(
|
|
8235
|
+
`createAgentRouter: every token must be at least ${MIN_AGENT_TOKEN_LENGTH} characters (got ${t?.token?.length ?? 0})`
|
|
8236
|
+
);
|
|
8237
|
+
}
|
|
8238
|
+
if (typeof t.actor !== "string" || !t.actor.trim()) {
|
|
8239
|
+
throw new Error('createAgentRouter: every token needs an actor label, e.g. "agent:claude"');
|
|
8240
|
+
}
|
|
8241
|
+
}
|
|
8242
|
+
const logger = opts.logger ?? consoleRouteLogger;
|
|
8243
|
+
const isTestContact = testContactMatcher(opts.testContacts);
|
|
8244
|
+
const c = mailer.collections;
|
|
8245
|
+
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
8246
|
+
const router = express.Router();
|
|
8247
|
+
router.use(express__default.default.json({ limit: "1mb" }));
|
|
8248
|
+
router.use(bearerAuth(opts.tokens));
|
|
8249
|
+
router.use("/api", createAdminApiRouter(mailer, { mailTesterClient: opts.mailTesterClient }));
|
|
8250
|
+
const actorOf = (req) => String(req.actor);
|
|
8251
|
+
function guardTestContact(res, contact) {
|
|
8252
|
+
if (!isTestContact) {
|
|
8253
|
+
res.status(403).json({
|
|
8254
|
+
error: "test_contacts_not_configured",
|
|
8255
|
+
message: "this route only acts on test contacts, and the router was constructed without a testContacts pattern"
|
|
8256
|
+
});
|
|
8257
|
+
return false;
|
|
8258
|
+
}
|
|
8259
|
+
if (!isTestContact(contact.email)) {
|
|
8260
|
+
res.status(403).json({
|
|
8261
|
+
error: "not_a_test_contact",
|
|
8262
|
+
message: `${contact.email} does not match the testContacts pattern`,
|
|
8263
|
+
externalId: contact.externalId
|
|
8264
|
+
});
|
|
8265
|
+
return false;
|
|
8266
|
+
}
|
|
8267
|
+
return true;
|
|
8268
|
+
}
|
|
8269
|
+
async function loadContact(res, externalId) {
|
|
8270
|
+
const contact = await mailer.adapter.getById(externalId);
|
|
8271
|
+
if (!contact) res.status(404).json({ error: "contact_not_found", externalId });
|
|
8272
|
+
return contact;
|
|
8273
|
+
}
|
|
8274
|
+
async function loadTemplate(res, slug) {
|
|
8275
|
+
const tpl = await c.templates.findOne({ slug });
|
|
8276
|
+
if (!tpl) res.status(404).json({ error: "template_not_found", slug });
|
|
8277
|
+
return tpl;
|
|
8278
|
+
}
|
|
8279
|
+
router.get("/", (req, res) => {
|
|
8280
|
+
res.json({
|
|
8281
|
+
service: "mailery-agent",
|
|
8282
|
+
version: VERSION,
|
|
8283
|
+
actor: actorOf(req),
|
|
8284
|
+
testContactsConfigured: !!isTestContact,
|
|
8285
|
+
docs: "https://jeffjassky.github.io/mailery/reference/agent-api",
|
|
8286
|
+
endpoints: ENDPOINTS
|
|
8287
|
+
});
|
|
8288
|
+
});
|
|
8289
|
+
router.post(
|
|
8290
|
+
"/templates/:slug/verify",
|
|
8291
|
+
wrap2(async (req, res) => {
|
|
8292
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8293
|
+
if (!tpl) return;
|
|
8294
|
+
const contact = await contactForRender(req, res);
|
|
8295
|
+
if (!contact) return;
|
|
8296
|
+
const report = await verifyTemplate(mailer, tpl, contact, {
|
|
8297
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8298
|
+
vars: objectOrUndefined(req.body?.vars),
|
|
8299
|
+
includeRendered: req.body?.includeRendered === true,
|
|
8300
|
+
varsSchema
|
|
8301
|
+
});
|
|
8302
|
+
res.status(200).json(report);
|
|
8303
|
+
})
|
|
8304
|
+
);
|
|
8305
|
+
router.post(
|
|
8306
|
+
"/templates/verify-all",
|
|
8307
|
+
wrap2(async (req, res) => {
|
|
8308
|
+
const slugs = Array.isArray(req.body?.slugs) ? req.body.slugs.map(String) : null;
|
|
8309
|
+
const contactIds = Array.isArray(req.body?.contactIds) ? req.body.contactIds.map(String) : [];
|
|
8310
|
+
if (contactIds.length === 0) {
|
|
8311
|
+
return res.status(400).json({ error: "validation_failed", message: "contactIds (non-empty array) is required" });
|
|
8312
|
+
}
|
|
8313
|
+
const templates = await c.templates.find(slugs ? { slug: { $in: slugs } } : {}).sort({ slug: 1 }).toArray();
|
|
8314
|
+
const contacts = [];
|
|
8315
|
+
for (const id of contactIds) {
|
|
8316
|
+
const found = await mailer.adapter.getById(id);
|
|
8317
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", externalId: id });
|
|
8318
|
+
contacts.push(found);
|
|
8319
|
+
}
|
|
8320
|
+
const results = [];
|
|
8321
|
+
for (const tpl of templates) {
|
|
8322
|
+
for (const contact of contacts) {
|
|
8323
|
+
const report = await verifyTemplate(mailer, tpl, contact, {
|
|
8324
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8325
|
+
includeRendered: false,
|
|
8326
|
+
varsSchema
|
|
8327
|
+
});
|
|
8328
|
+
results.push({
|
|
8329
|
+
slug: tpl.slug,
|
|
8330
|
+
contactId: contact.externalId,
|
|
8331
|
+
ok: report.ok,
|
|
8332
|
+
failed: report.checks.filter((k) => k.status === "fail").map((k) => k.id),
|
|
8333
|
+
warned: report.checks.filter((k) => k.status === "warn").map((k) => k.id)
|
|
8334
|
+
});
|
|
8335
|
+
}
|
|
8336
|
+
}
|
|
8337
|
+
const failing = results.filter((r) => !r.ok);
|
|
8338
|
+
res.json({
|
|
8339
|
+
ok: failing.length === 0,
|
|
8340
|
+
templates: templates.length,
|
|
8341
|
+
contacts: contacts.length,
|
|
8342
|
+
verified: results.length,
|
|
8343
|
+
failing: failing.length,
|
|
8344
|
+
results
|
|
8345
|
+
});
|
|
8346
|
+
})
|
|
8347
|
+
);
|
|
8348
|
+
router.post(
|
|
8349
|
+
"/templates/:slug/render",
|
|
8350
|
+
wrap2(async (req, res) => {
|
|
8351
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8352
|
+
if (!tpl) return;
|
|
8353
|
+
const contact = await contactForRender(req, res);
|
|
8354
|
+
if (!contact) return;
|
|
8355
|
+
const out = await renderForContact(mailer, tpl, contact, {
|
|
8356
|
+
reason: "preview",
|
|
8357
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8358
|
+
vars: objectOrUndefined(req.body?.vars)
|
|
8359
|
+
});
|
|
8360
|
+
res.json({
|
|
8361
|
+
template: { slug: tpl.slug, kind: tpl.kind },
|
|
8362
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8363
|
+
subject: out.rendered.subject,
|
|
8364
|
+
preheader: out.rendered.preheader,
|
|
8365
|
+
fromName: out.rendered.fromName,
|
|
8366
|
+
fromEmail: out.rendered.fromEmail,
|
|
8367
|
+
replyTo: out.rendered.replyTo,
|
|
8368
|
+
html: out.rendered.html,
|
|
8369
|
+
plainText: out.rendered.plainText,
|
|
8370
|
+
resolvedVars: out.resolved,
|
|
8371
|
+
unsubscribeUrl: out.unsubscribeUrl
|
|
8372
|
+
});
|
|
8373
|
+
})
|
|
8374
|
+
);
|
|
8375
|
+
router.post(
|
|
8376
|
+
"/templates/:slug/send",
|
|
8377
|
+
wrap2(async (req, res) => {
|
|
8378
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8379
|
+
if (!tpl) return;
|
|
8380
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8381
|
+
if (!contactId) return res.status(400).json({ error: "validation_failed", message: "contactId is required" });
|
|
8382
|
+
const contact = await loadContact(res, contactId);
|
|
8383
|
+
if (!contact) return;
|
|
8384
|
+
if (!guardTestContact(res, contact)) return;
|
|
8385
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
8386
|
+
return res.status(409).json({ error: "not_published", message: "template has no published body" });
|
|
8387
|
+
}
|
|
8388
|
+
const dedupeKey = typeof req.body?.dedupeKey === "string" && req.body.dedupeKey ? String(req.body.dedupeKey) : `agent:${crypto2__default.default.randomUUID()}`;
|
|
8389
|
+
const { sendId } = await mailer.sendOneOff({
|
|
8390
|
+
templateSlug: tpl.slug,
|
|
8391
|
+
externalId: contact.externalId,
|
|
8392
|
+
dedupeKey,
|
|
8393
|
+
vars: objectOrUndefined(req.body?.vars)
|
|
8394
|
+
});
|
|
8395
|
+
const dispatchNow = req.body?.dispatch !== "queue";
|
|
8396
|
+
if (dispatchNow) {
|
|
8397
|
+
await dispatchSend(new mongodb.ObjectId(sendId), mailer.getRunnerContext());
|
|
8398
|
+
}
|
|
8399
|
+
const send = await c.sends.findOne({ _id: new mongodb.ObjectId(sendId) });
|
|
8400
|
+
await mailer.audit({
|
|
8401
|
+
actor: actorOf(req),
|
|
8402
|
+
action: "agent.send",
|
|
8403
|
+
resource: { collection: "mailer_sends", id: new mongodb.ObjectId(sendId), slug: tpl.slug },
|
|
8404
|
+
diffSummary: `to=${contact.email} dispatch=${dispatchNow ? "now" : "queue"} dedupeKey=${dedupeKey}`
|
|
8405
|
+
});
|
|
8406
|
+
res.status(201).json({ sendId, dedupeKey, dispatched: dispatchNow, send: send ? sendSummary(send) : null });
|
|
8407
|
+
})
|
|
8408
|
+
);
|
|
8409
|
+
router.get(
|
|
8410
|
+
"/sends/:id/wait",
|
|
8411
|
+
wrap2(async (req, res) => {
|
|
8412
|
+
const id = String(req.params.id);
|
|
8413
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8414
|
+
const target = String(req.query.status ?? "delivered");
|
|
8415
|
+
if (!WAIT_TARGETS.has(target)) {
|
|
8416
|
+
return res.status(400).json({ error: "validation_failed", message: `status must be one of ${[...WAIT_TARGETS].join(", ")}` });
|
|
8417
|
+
}
|
|
8418
|
+
const timeoutMs = Math.max(0, Math.min(Number(req.query.timeoutMs ?? 3e4) || 0, 55e3));
|
|
8419
|
+
const started = Date.now();
|
|
8420
|
+
let send = null;
|
|
8421
|
+
let reached = false;
|
|
8422
|
+
for (; ; ) {
|
|
8423
|
+
send = await c.sends.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8424
|
+
if (!send) return res.status(404).json({ error: "not_found" });
|
|
8425
|
+
reached = waitTargetReached(send, target);
|
|
8426
|
+
if (reached || Date.now() - started >= timeoutMs) break;
|
|
8427
|
+
await sleep2(1e3);
|
|
8428
|
+
}
|
|
8429
|
+
const webhookEvents = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).sort({ receivedAt: 1 }).limit(100).toArray() : [];
|
|
8430
|
+
res.json({ reached, target, waitedMs: Date.now() - started, send: sendSummary(send), webhookEvents });
|
|
8431
|
+
})
|
|
8432
|
+
);
|
|
8433
|
+
router.post(
|
|
8434
|
+
"/sends/:id/dispatch",
|
|
8435
|
+
wrap2(async (req, res) => {
|
|
8436
|
+
const id = String(req.params.id);
|
|
8437
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8438
|
+
const send = await c.sends.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8439
|
+
if (!send) return res.status(404).json({ error: "not_found" });
|
|
8440
|
+
const contact = await loadContact(res, send.externalId);
|
|
8441
|
+
if (!contact) return;
|
|
8442
|
+
if (!guardTestContact(res, contact)) return;
|
|
8443
|
+
await dispatchSend(send._id, mailer.getRunnerContext());
|
|
8444
|
+
const after = await c.sends.findOne({ _id: send._id });
|
|
8445
|
+
res.json({ send: after ? sendSummary(after) : null });
|
|
8446
|
+
})
|
|
8447
|
+
);
|
|
8448
|
+
router.post(
|
|
8449
|
+
"/flows/:slug/simulate",
|
|
8450
|
+
wrap2(async (req, res) => {
|
|
8451
|
+
const flow = await c.flows.findOne({ slug: String(req.params.slug) });
|
|
8452
|
+
if (!flow) return res.status(404).json({ error: "flow_not_found", slug: String(req.params.slug) });
|
|
8453
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8454
|
+
if (!contactId) return res.status(400).json({ error: "validation_failed", message: "contactId is required" });
|
|
8455
|
+
const contact = await loadContact(res, contactId);
|
|
8456
|
+
if (!contact) return;
|
|
8457
|
+
let at;
|
|
8458
|
+
if (req.body?.at) {
|
|
8459
|
+
at = new Date(String(req.body.at));
|
|
8460
|
+
if (Number.isNaN(at.getTime())) return res.status(400).json({ error: "validation_failed", message: "at must be an ISO date" });
|
|
8461
|
+
}
|
|
8462
|
+
let steps = void 0;
|
|
8463
|
+
if (req.body?.version !== void 0) {
|
|
8464
|
+
const v = Number(req.body.version);
|
|
8465
|
+
if (v !== flow.version) {
|
|
8466
|
+
const snap = await c.flowVersions.findOne({ flowId: flow._id, version: v });
|
|
8467
|
+
if (!snap) return res.status(404).json({ error: "version_not_found", version: v });
|
|
8468
|
+
steps = snap.steps;
|
|
8469
|
+
}
|
|
8470
|
+
}
|
|
8471
|
+
const result = await simulateFlow(flow, contact, mailer.getRunnerContext(), {
|
|
8472
|
+
at,
|
|
8473
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8474
|
+
steps
|
|
8475
|
+
});
|
|
8476
|
+
res.json(result);
|
|
8477
|
+
})
|
|
8478
|
+
);
|
|
8479
|
+
router.post(
|
|
8480
|
+
"/flows/:slug/arm",
|
|
8481
|
+
wrap2(async (req, res) => {
|
|
8482
|
+
if (req.body?.confirm !== true) {
|
|
8483
|
+
return res.status(400).json({
|
|
8484
|
+
error: "confirm_required",
|
|
8485
|
+
message: 'arming enables a flow for every future matching event; pass {"confirm": true}'
|
|
8486
|
+
});
|
|
8487
|
+
}
|
|
8488
|
+
let since;
|
|
8489
|
+
if (req.body?.since) {
|
|
8490
|
+
since = new Date(String(req.body.since));
|
|
8491
|
+
if (Number.isNaN(since.getTime())) return res.status(400).json({ error: "validation_failed", message: "since must be an ISO date" });
|
|
8492
|
+
}
|
|
8493
|
+
const result = await armFlow(mailer, String(req.params.slug), { actor: actorOf(req), since });
|
|
8494
|
+
res.json(result);
|
|
8495
|
+
})
|
|
8496
|
+
);
|
|
8497
|
+
router.post(
|
|
8498
|
+
"/flows/:slug/disarm",
|
|
8499
|
+
wrap2(async (req, res) => {
|
|
8500
|
+
res.json(await disarmFlow(mailer, String(req.params.slug), actorOf(req)));
|
|
8501
|
+
})
|
|
8502
|
+
);
|
|
8503
|
+
router.post(
|
|
8504
|
+
"/flows/:slug/gate",
|
|
8505
|
+
wrap2(async (req, res) => {
|
|
8506
|
+
const tag = typeof req.body?.tag === "string" ? req.body.tag : "";
|
|
8507
|
+
res.json(await gateFlow(mailer, String(req.params.slug), { tag, actor: actorOf(req) }));
|
|
8508
|
+
})
|
|
8509
|
+
);
|
|
8510
|
+
router.post(
|
|
8511
|
+
"/flows/:slug/ungate",
|
|
8512
|
+
wrap2(async (req, res) => {
|
|
8513
|
+
res.json(await ungateFlow(mailer, String(req.params.slug), { actor: actorOf(req) }));
|
|
8514
|
+
})
|
|
8515
|
+
);
|
|
8516
|
+
router.get(
|
|
8517
|
+
"/runs",
|
|
8518
|
+
wrap2(async (req, res) => {
|
|
8519
|
+
const filter = {};
|
|
8520
|
+
if (typeof req.query.externalId === "string") filter.externalId = req.query.externalId;
|
|
8521
|
+
if (typeof req.query.flowSlug === "string") filter.flowSlug = req.query.flowSlug;
|
|
8522
|
+
if (typeof req.query.status === "string") filter.status = req.query.status;
|
|
8523
|
+
const limit = Math.min(Math.max(Number(req.query.limit ?? 50) || 50, 1), 200);
|
|
8524
|
+
const runs = await c.flowRuns.find(filter).sort({ enteredAt: -1 }).limit(limit).toArray();
|
|
8525
|
+
res.json(runs.map(runSummary));
|
|
8526
|
+
})
|
|
8527
|
+
);
|
|
8528
|
+
router.get(
|
|
8529
|
+
"/runs/:id",
|
|
8530
|
+
wrap2(async (req, res) => {
|
|
8531
|
+
const id = String(req.params.id);
|
|
8532
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8533
|
+
const run = await c.flowRuns.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8534
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8535
|
+
const sends = await c.sends.find({ flowRunId: run._id }).sort({ queuedAt: 1 }).toArray();
|
|
8536
|
+
res.json({ run, sends: sends.map(sendSummary) });
|
|
8537
|
+
})
|
|
8538
|
+
);
|
|
8539
|
+
router.post(
|
|
8540
|
+
"/runs/:id/advance",
|
|
8541
|
+
wrap2(async (req, res) => {
|
|
8542
|
+
const id = String(req.params.id);
|
|
8543
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8544
|
+
const run = await c.flowRuns.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8545
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8546
|
+
const contact = await loadContact(res, run.externalId);
|
|
8547
|
+
if (!contact) return;
|
|
8548
|
+
if (!guardTestContact(res, contact)) return;
|
|
8549
|
+
if (run.status !== "active") {
|
|
8550
|
+
return res.status(409).json({ error: "run_not_active", status: run.status, exitReason: run.exitReason });
|
|
8551
|
+
}
|
|
8552
|
+
const steps = Math.min(Math.max(Number(req.body?.steps ?? 1) || 1, 1), 50);
|
|
8553
|
+
const dispatch = req.body?.dispatch !== false;
|
|
8554
|
+
const ctx = mailer.getRunnerContext();
|
|
8555
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
8556
|
+
const historyBefore = run.history.length;
|
|
8557
|
+
const actor = actorOf(req);
|
|
8558
|
+
for (let i = 0; i < steps; i += 1) {
|
|
8559
|
+
const advanced = await advanceOnce(run._id, ctx, actor, c);
|
|
8560
|
+
if (!advanced) break;
|
|
8561
|
+
}
|
|
8562
|
+
const after = await c.flowRuns.findOne({ _id: run._id });
|
|
8563
|
+
const newSends = await c.sends.find({ flowRunId: run._id, queuedAt: { $gte: startedAt } }).toArray();
|
|
8564
|
+
if (dispatch) {
|
|
8565
|
+
for (const s of newSends) {
|
|
8566
|
+
if (s.status === "queued") await dispatchSend(s._id, ctx);
|
|
8567
|
+
}
|
|
8568
|
+
}
|
|
8569
|
+
const sends = await c.sends.find({ flowRunId: run._id, queuedAt: { $gte: startedAt } }).toArray();
|
|
8570
|
+
await mailer.audit({
|
|
8571
|
+
actor,
|
|
8572
|
+
action: "agent.run.advance",
|
|
8573
|
+
resource: { collection: "mailer_flow_runs", id: run._id, slug: run.flowSlug },
|
|
8574
|
+
diffSummary: `advanced ${steps} step(s) for ${contact.email}; ${sends.length} send(s) created`
|
|
8575
|
+
});
|
|
8576
|
+
res.json({
|
|
8577
|
+
run: after ? runSummary(after) : null,
|
|
8578
|
+
historyAdded: after ? after.history.slice(historyBefore) : [],
|
|
8579
|
+
sends: sends.map(sendSummary)
|
|
8580
|
+
});
|
|
8581
|
+
})
|
|
8582
|
+
);
|
|
8583
|
+
router.post(
|
|
8584
|
+
"/runs/:id/cancel",
|
|
8585
|
+
wrap2(async (req, res) => {
|
|
8586
|
+
const id = String(req.params.id);
|
|
8587
|
+
if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8588
|
+
const run = await c.flowRuns.findOne({ _id: new mongodb.ObjectId(id) });
|
|
8589
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8590
|
+
if (run.status !== "active") return res.status(409).json({ error: "run_not_active", status: run.status });
|
|
8591
|
+
const actor = actorOf(req);
|
|
8592
|
+
await exitFlowRun(run, `aborted_by_host:${actor}`, mailer.getRunnerContext());
|
|
8593
|
+
const cancelled = await c.sends.updateMany(
|
|
8594
|
+
{ flowRunId: run._id, status: "queued" },
|
|
8595
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: aborted_by_host:${actor}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
8596
|
+
);
|
|
8597
|
+
await mailer.audit({
|
|
8598
|
+
actor,
|
|
8599
|
+
action: "agent.run.cancel",
|
|
8600
|
+
resource: { collection: "mailer_flow_runs", id: run._id, slug: run.flowSlug },
|
|
8601
|
+
diffSummary: `cancelled run for ${run.externalId}; ${cancelled.modifiedCount} queued send(s) cancelled`
|
|
8602
|
+
});
|
|
8603
|
+
const after = await c.flowRuns.findOne({ _id: run._id });
|
|
8604
|
+
res.json({ run: after ? runSummary(after) : null, cancelledSends: cancelled.modifiedCount });
|
|
8605
|
+
})
|
|
8606
|
+
);
|
|
8607
|
+
router.post(
|
|
8608
|
+
"/events",
|
|
8609
|
+
wrap2(async (req, res) => {
|
|
8610
|
+
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
|
8611
|
+
const externalId = typeof req.body?.externalId === "string" ? req.body.externalId : "";
|
|
8612
|
+
if (!name || !externalId) {
|
|
8613
|
+
return res.status(400).json({ error: "validation_failed", message: "name and externalId are required" });
|
|
8614
|
+
}
|
|
8615
|
+
const contact = await loadContact(res, externalId);
|
|
8616
|
+
if (!contact) return;
|
|
8617
|
+
if (!guardTestContact(res, contact)) return;
|
|
8618
|
+
const dedupeKey = typeof req.body?.dedupeKey === "string" && req.body.dedupeKey ? req.body.dedupeKey : void 0;
|
|
8619
|
+
try {
|
|
8620
|
+
await mailer.fire(name, externalId, objectOrUndefined(req.body?.properties) ?? {}, dedupeKey);
|
|
8621
|
+
} catch (err) {
|
|
8622
|
+
return res.status(400).json({ error: "fire_failed", message: String(err?.message ?? err) });
|
|
8623
|
+
}
|
|
8624
|
+
const latest = await c.events.findOne({ externalId, name }, { sort: { createdAt: -1 } });
|
|
8625
|
+
await mailer.audit({
|
|
8626
|
+
actor: actorOf(req),
|
|
8627
|
+
action: "agent.event.fire",
|
|
8628
|
+
resource: { collection: "mailer_events", id: latest?._id },
|
|
8629
|
+
diffSummary: `${name} for ${contact.email}${dedupeKey ? ` key=${dedupeKey}` : ""}`
|
|
8630
|
+
});
|
|
8631
|
+
res.status(201).json({ ok: true, event: latest });
|
|
8632
|
+
})
|
|
8633
|
+
);
|
|
8634
|
+
router.get(
|
|
8635
|
+
"/contacts/by-email/:email",
|
|
8636
|
+
wrap2(async (req, res) => {
|
|
8637
|
+
const contact = await mailer.adapter.getByEmail(String(req.params.email).toLowerCase());
|
|
8638
|
+
if (!contact) return res.status(404).json({ error: "contact_not_found", email: req.params.email });
|
|
8639
|
+
res.json(await contactDetail(contact));
|
|
8640
|
+
})
|
|
8641
|
+
);
|
|
8642
|
+
router.get(
|
|
8643
|
+
"/contacts/:externalId",
|
|
8644
|
+
wrap2(async (req, res) => {
|
|
8645
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8646
|
+
if (!contact) return;
|
|
8647
|
+
res.json(await contactDetail(contact));
|
|
8648
|
+
})
|
|
8649
|
+
);
|
|
8650
|
+
router.get(
|
|
8651
|
+
"/contacts/:externalId/unsubscribe-url",
|
|
8652
|
+
wrap2(async (req, res) => {
|
|
8653
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8654
|
+
if (!contact) return;
|
|
8655
|
+
if (!guardTestContact(res, contact)) return;
|
|
8656
|
+
res.json({ contact: { externalId: contact.externalId, email: contact.email }, unsubscribeUrl: unsubscribeUrlFor(mailer, contact.email) });
|
|
8657
|
+
})
|
|
8658
|
+
);
|
|
8659
|
+
router.post(
|
|
8660
|
+
"/contacts/:externalId/subscribe",
|
|
8661
|
+
wrap2(async (req, res) => {
|
|
8662
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8663
|
+
if (!contact) return;
|
|
8664
|
+
if (!guardTestContact(res, contact)) return;
|
|
8665
|
+
await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent" });
|
|
8666
|
+
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8667
|
+
await mailer.audit({
|
|
8668
|
+
actor: actorOf(req),
|
|
8669
|
+
action: "agent.contact.subscribe",
|
|
8670
|
+
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8671
|
+
diffSummary: contact.email
|
|
8672
|
+
});
|
|
8673
|
+
res.json({ subscription: sub });
|
|
8674
|
+
})
|
|
8675
|
+
);
|
|
8676
|
+
router.post(
|
|
8677
|
+
"/contacts/:externalId/unsubscribe",
|
|
8678
|
+
wrap2(async (req, res) => {
|
|
8679
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8680
|
+
if (!contact) return;
|
|
8681
|
+
if (!guardTestContact(res, contact)) return;
|
|
8682
|
+
await mailer.unsubscribe(contact.email, { scope: "marketing", reason: "user_request", source: "agent" });
|
|
8683
|
+
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8684
|
+
await mailer.audit({
|
|
8685
|
+
actor: actorOf(req),
|
|
8686
|
+
action: "agent.contact.unsubscribe",
|
|
8687
|
+
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8688
|
+
diffSummary: contact.email
|
|
8689
|
+
});
|
|
8690
|
+
res.json({ subscription: sub });
|
|
8691
|
+
})
|
|
8692
|
+
);
|
|
8693
|
+
router.post(
|
|
8694
|
+
"/contacts/:externalId/reset",
|
|
8695
|
+
wrap2(async (req, res) => {
|
|
8696
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8697
|
+
if (!contact) return;
|
|
8698
|
+
if (!guardTestContact(res, contact)) return;
|
|
8699
|
+
const b = req.body ?? {};
|
|
8700
|
+
const eventFilter = { externalId: contact.externalId };
|
|
8701
|
+
if (Array.isArray(b.events)) eventFilter.name = { $in: b.events.map(String) };
|
|
8702
|
+
const doRuns = b.runs !== false;
|
|
8703
|
+
const doSends = b.sends !== false;
|
|
8704
|
+
const doEvents = b.events !== false;
|
|
8705
|
+
const doSuppressions = b.suppressions !== false;
|
|
8706
|
+
const doSubscribe = b.subscribe !== false;
|
|
8707
|
+
const removed = { runs: 0, sends: 0, events: 0, suppressions: 0 };
|
|
8708
|
+
if (doRuns) removed.runs = (await c.flowRuns.deleteMany({ externalId: contact.externalId })).deletedCount;
|
|
8709
|
+
if (doSends) removed.sends = (await c.sends.deleteMany({ externalId: contact.externalId })).deletedCount;
|
|
8710
|
+
if (doEvents) removed.events = (await c.events.deleteMany(eventFilter)).deletedCount;
|
|
8711
|
+
if (doSuppressions) removed.suppressions = (await c.suppressions.deleteMany({ email: contact.email })).deletedCount;
|
|
8712
|
+
if (doSubscribe) await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent-reset" });
|
|
8713
|
+
const subscription = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8714
|
+
await mailer.audit({
|
|
8715
|
+
actor: actorOf(req),
|
|
8716
|
+
action: "agent.contact.reset",
|
|
8717
|
+
resource: { collection: "mailer_subscriptions", id: subscription?._id },
|
|
8718
|
+
diffSummary: `${contact.email}: removed ${removed.runs} run(s), ${removed.sends} send(s), ${removed.events} event(s), ${removed.suppressions} suppression(s)${doSubscribe ? "; resubscribed" : ""}`
|
|
8719
|
+
});
|
|
8720
|
+
res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
|
|
8721
|
+
})
|
|
8722
|
+
);
|
|
8723
|
+
router.post(
|
|
8724
|
+
"/tick",
|
|
8725
|
+
wrap2(async (_req, res) => {
|
|
8726
|
+
const started = Date.now();
|
|
8727
|
+
await runTick(mailer.getRunnerContext());
|
|
8728
|
+
res.json({ ok: true, ms: Date.now() - started });
|
|
8729
|
+
})
|
|
8730
|
+
);
|
|
8731
|
+
router.get(
|
|
8732
|
+
"/webhooks/status",
|
|
8733
|
+
wrap2(async (_req, res) => {
|
|
8734
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
8735
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
|
|
8736
|
+
const [last, byType24h, total7d, unprocessed] = await Promise.all([
|
|
8737
|
+
c.webhookEvents.findOne({}, { sort: { receivedAt: -1 }, projection: { receivedAt: 1, provider: 1, normalizedType: 1 } }),
|
|
8738
|
+
c.webhookEvents.aggregate([{ $match: { receivedAt: { $gte: dayAgo } } }, { $group: { _id: "$normalizedType", n: { $sum: 1 } } }]).toArray(),
|
|
8739
|
+
c.webhookEvents.countDocuments({ receivedAt: { $gte: weekAgo } }),
|
|
8740
|
+
c.webhookEvents.countDocuments({ processed: false })
|
|
8741
|
+
]);
|
|
8742
|
+
res.json({
|
|
8743
|
+
providers: Object.keys(mailer.providers),
|
|
8744
|
+
lastReceivedAt: last?.receivedAt ?? null,
|
|
8745
|
+
lastProvider: last?.provider ?? null,
|
|
8746
|
+
last24h: Object.fromEntries(byType24h.map((r) => [r._id, r.n])),
|
|
8747
|
+
last7d: total7d,
|
|
8748
|
+
unprocessed,
|
|
8749
|
+
ingestPath: `${mailer.config.publicUrl}/m/webhooks/<provider>`
|
|
8750
|
+
});
|
|
8751
|
+
})
|
|
8752
|
+
);
|
|
8753
|
+
router.get(
|
|
8754
|
+
"/status",
|
|
8755
|
+
wrap2(async (_req, res) => {
|
|
8756
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
8757
|
+
const [flows, templates, subscribed, suppressions, activeRuns, sends24h, webhooks24h, lastWebhook, healthDocs, setup] = await Promise.all([
|
|
8758
|
+
c.flows.find({}, { projection: { slug: 1, enabled: 1, version: 1, lastTriggerScanAt: 1, steps: 1, trigger: 1 } }).sort({ slug: 1 }).toArray(),
|
|
8759
|
+
c.templates.find({}, { projection: { slug: 1, kind: 1, publishedAt: 1, "body.html": 1, fromEmail: 1 } }).sort({ slug: 1 }).toArray(),
|
|
8760
|
+
c.subscriptions.countDocuments({ status: "subscribed" }),
|
|
8761
|
+
c.suppressions.estimatedDocumentCount(),
|
|
8762
|
+
c.flowRuns.countDocuments({ status: "active" }),
|
|
8763
|
+
c.sends.aggregate([{ $match: { queuedAt: { $gte: dayAgo } } }, { $group: { _id: "$status", n: { $sum: 1 } } }]).toArray(),
|
|
8764
|
+
c.webhookEvents.countDocuments({ receivedAt: { $gte: dayAgo } }),
|
|
8765
|
+
c.webhookEvents.findOne({}, { sort: { receivedAt: -1 }, projection: { receivedAt: 1 } }),
|
|
8766
|
+
c.health.find({}).limit(500).toArray(),
|
|
8767
|
+
runSetupChecks(mailer)
|
|
8768
|
+
]);
|
|
8769
|
+
const activeByFlow = await c.flowRuns.aggregate([{ $match: { status: "active" } }, { $group: { _id: "$flowSlug", n: { $sum: 1 } } }]).toArray();
|
|
8770
|
+
const activeMap = new Map(activeByFlow.map((r) => [r._id, r.n]));
|
|
8771
|
+
res.json({
|
|
8772
|
+
version: VERSION,
|
|
8773
|
+
now: /* @__PURE__ */ new Date(),
|
|
8774
|
+
testContactsConfigured: !!isTestContact,
|
|
8775
|
+
setup,
|
|
8776
|
+
health: { status: healthDocs.length ? effectiveOverallStatus(healthDocs) : null, aggregate: healthDocs.find((d) => d._id === HEALTH_AGG_ID) ?? null },
|
|
8777
|
+
flows: flows.map((f) => ({
|
|
8778
|
+
slug: f.slug,
|
|
8779
|
+
enabled: f.enabled,
|
|
8780
|
+
version: f.version,
|
|
8781
|
+
lastTriggerScanAt: f.lastTriggerScanAt ?? null,
|
|
8782
|
+
trigger: f.trigger,
|
|
8783
|
+
liveSteps: Array.isArray(f.steps) ? f.steps.length : 0,
|
|
8784
|
+
gated: Array.isArray(f.steps) && isCanaryGate(f.steps[0]) ? f.steps[0].test.hasTag : null,
|
|
8785
|
+
activeRuns: activeMap.get(f.slug) ?? 0
|
|
8786
|
+
})),
|
|
8787
|
+
templates: templates.map((t) => ({
|
|
8788
|
+
slug: t.slug,
|
|
8789
|
+
kind: t.kind,
|
|
8790
|
+
fromEmail: t.fromEmail,
|
|
8791
|
+
published: !!(t.body && t.body.html),
|
|
8792
|
+
publishedAt: t.publishedAt ?? null
|
|
8793
|
+
})),
|
|
8794
|
+
counts: {
|
|
8795
|
+
subscribed,
|
|
8796
|
+
suppressions,
|
|
8797
|
+
activeRuns,
|
|
8798
|
+
sendsLast24h: Object.fromEntries(sends24h.map((r) => [r._id, r.n])),
|
|
8799
|
+
webhookEventsLast24h: webhooks24h,
|
|
8800
|
+
lastWebhookAt: lastWebhook?.receivedAt ?? null
|
|
8801
|
+
}
|
|
8802
|
+
});
|
|
8803
|
+
})
|
|
8804
|
+
);
|
|
8805
|
+
router.use((req, res) => {
|
|
8806
|
+
res.status(404).json({ error: "not_found", message: `no agent route ${req.method} ${req.path}; GET / lists them` });
|
|
8807
|
+
});
|
|
8808
|
+
router.use((err, req, res, _next) => {
|
|
8809
|
+
if (err instanceof FlowOperationError) {
|
|
8810
|
+
return res.status(err.status).json({ error: err.code, message: err.message });
|
|
8811
|
+
}
|
|
8812
|
+
if (err?.name === "ZodError") {
|
|
8813
|
+
return res.status(400).json({ error: "validation_failed", issues: err.issues });
|
|
8814
|
+
}
|
|
8815
|
+
logger.error?.({ err: String(err?.message ?? err), path: req.path, method: req.method }, "mailery agent: request failed");
|
|
8816
|
+
if (res.headersSent) return;
|
|
8817
|
+
res.status(500).json({ error: "internal", message: String(err?.message ?? err) });
|
|
8818
|
+
});
|
|
8819
|
+
return router;
|
|
8820
|
+
async function contactForRender(req, res) {
|
|
8821
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8822
|
+
if (contactId) return loadContact(res, contactId);
|
|
8823
|
+
const sample = req.body?.sampleContact;
|
|
8824
|
+
if (sample && typeof sample === "object" && typeof sample.email === "string") {
|
|
8825
|
+
return {
|
|
8826
|
+
externalId: String(sample.externalId ?? "sample-contact"),
|
|
8827
|
+
email: sample.email,
|
|
8828
|
+
tags: Array.isArray(sample.tags) ? sample.tags.map(String) : [],
|
|
8829
|
+
fields: sample.fields && typeof sample.fields === "object" ? sample.fields : {},
|
|
8830
|
+
timezone: typeof sample.timezone === "string" ? sample.timezone : void 0
|
|
8831
|
+
};
|
|
8832
|
+
}
|
|
8833
|
+
res.status(400).json({ error: "validation_failed", message: "contactId (or a sampleContact with an email) is required" });
|
|
8834
|
+
return null;
|
|
8835
|
+
}
|
|
8836
|
+
async function contactDetail(contact) {
|
|
8837
|
+
const [subscription, recentEvents, recentSends, runs, suppressions] = await Promise.all([
|
|
8838
|
+
c.subscriptions.findOne({ externalId: contact.externalId }),
|
|
8839
|
+
c.events.find({ externalId: contact.externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
|
|
8840
|
+
c.sends.find({ externalId: contact.externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
|
|
8841
|
+
c.flowRuns.find({ externalId: contact.externalId }).sort({ enteredAt: -1 }).limit(50).toArray(),
|
|
8842
|
+
c.suppressions.find({ email: contact.email }).toArray()
|
|
8843
|
+
]);
|
|
8844
|
+
return {
|
|
8845
|
+
contact,
|
|
8846
|
+
isTestContact: isTestContact ? isTestContact(contact.email) : null,
|
|
8847
|
+
subscription,
|
|
8848
|
+
suppressions,
|
|
8849
|
+
recentEvents,
|
|
8850
|
+
recentSends: recentSends.map(sendSummary),
|
|
8851
|
+
runs: runs.map(runSummary)
|
|
8852
|
+
};
|
|
8853
|
+
}
|
|
8854
|
+
}
|
|
8855
|
+
var PLACEHOLDER_RE = /\{\{[^{}]*\}\}|\{\{\{[^{}]*\}\}\}/g;
|
|
8856
|
+
var HREF_RE = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
8857
|
+
var GMAIL_CLIP_BYTES = 102 * 1024;
|
|
8858
|
+
async function verifyTemplate(mailer, tpl, contact, opts = {}) {
|
|
8859
|
+
const checks = [];
|
|
8860
|
+
const push = (id, status, detail) => checks.push(detail === void 0 ? { id, status } : { id, status, detail });
|
|
8861
|
+
const base = {
|
|
8862
|
+
template: { slug: tpl.slug, kind: tpl.kind, name: tpl.name },
|
|
8863
|
+
contact: { externalId: contact.externalId, email: contact.email }
|
|
8864
|
+
};
|
|
8865
|
+
const finish = (rendered2, links2) => ({
|
|
8866
|
+
ok: checks.every((k) => k.status !== "fail"),
|
|
8867
|
+
...base,
|
|
8868
|
+
checks,
|
|
8869
|
+
links: { total: links2.length, sample: links2.slice(0, 50) },
|
|
8870
|
+
rendered: rendered2 ? opts.includeRendered ? {
|
|
8871
|
+
subject: rendered2.subject,
|
|
8872
|
+
preheader: rendered2.preheader,
|
|
8873
|
+
html: rendered2.html,
|
|
8874
|
+
plainText: rendered2.plainText,
|
|
8875
|
+
fromEmail: rendered2.fromEmail,
|
|
8876
|
+
fromName: rendered2.fromName,
|
|
8877
|
+
replyTo: rendered2.replyTo
|
|
8878
|
+
} : {
|
|
8879
|
+
subject: rendered2.subject,
|
|
8880
|
+
preheader: rendered2.preheader,
|
|
8881
|
+
htmlBytes: Buffer.byteLength(rendered2.html, "utf8"),
|
|
8882
|
+
textLength: rendered2.plainText.trim().length,
|
|
8883
|
+
fromEmail: rendered2.fromEmail
|
|
8884
|
+
} : null
|
|
8885
|
+
});
|
|
8886
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
8887
|
+
push("published", "fail", "template has no published body (body.html and body.mjml are empty)");
|
|
8888
|
+
return finish(null, []);
|
|
8889
|
+
}
|
|
8890
|
+
push("published", "pass", { publishedAt: tpl.publishedAt ?? null });
|
|
8891
|
+
let out;
|
|
8892
|
+
try {
|
|
8893
|
+
out = await renderForContact(mailer, tpl, contact, {
|
|
8894
|
+
reason: "test",
|
|
8895
|
+
eventProperties: opts.eventProperties,
|
|
8896
|
+
vars: opts.vars
|
|
8897
|
+
});
|
|
8898
|
+
push("vars_resolved", "pass", { keys: Object.keys(out.resolved) });
|
|
8899
|
+
push("render", "pass");
|
|
8900
|
+
} catch (err) {
|
|
8901
|
+
const message = String(err?.message ?? err);
|
|
8902
|
+
push(message.startsWith("varsAdapter") ? "vars_resolved" : "render", "fail", message);
|
|
8903
|
+
return finish(null, []);
|
|
8904
|
+
}
|
|
8905
|
+
const { rendered, unsubscribeUrl } = out;
|
|
8906
|
+
const leftovers = /* @__PURE__ */ new Set();
|
|
8907
|
+
for (const part of [rendered.subject, rendered.preheader, rendered.html, rendered.plainText]) {
|
|
8908
|
+
for (const m of part.matchAll(PLACEHOLDER_RE)) leftovers.add(m[0]);
|
|
8909
|
+
}
|
|
8910
|
+
push("unresolved_placeholders", leftovers.size ? "fail" : "pass", leftovers.size ? { placeholders: [...leftovers] } : void 0);
|
|
8911
|
+
const referenced = referencedPaths(
|
|
8912
|
+
[tpl.subject, tpl.preheader, tpl.body.html, tpl.body.plainText, tpl.body.mjml ?? ""].join("\n"),
|
|
8913
|
+
Object.keys(mailer.config.handlebarsHelpers ?? {})
|
|
8914
|
+
);
|
|
8915
|
+
const missing = [];
|
|
8916
|
+
const empty = [];
|
|
8917
|
+
for (const path3 of referenced) {
|
|
8918
|
+
const value = lookupPath(out.context, path3);
|
|
8919
|
+
if (value === void 0) {
|
|
8920
|
+
const root = path3.split(".")[0];
|
|
8921
|
+
if (exports.RESERVED_VAR_KEYS.includes(root)) empty.push(path3);
|
|
8922
|
+
else missing.push(path3);
|
|
8923
|
+
} else if (value === null || value === "") {
|
|
8924
|
+
empty.push(path3);
|
|
8925
|
+
}
|
|
8926
|
+
}
|
|
8927
|
+
push("unknown_variables", missing.length ? "fail" : "pass", { referenced: referenced.length, missing });
|
|
8928
|
+
push("empty_variables", empty.length ? "warn" : "pass", { empty });
|
|
8929
|
+
const links = extractLinks(rendered.html);
|
|
8930
|
+
const invalid = links.filter((l) => !/^(https?:\/\/|mailto:|tel:)/i.test(l));
|
|
8931
|
+
push("links_absolute", invalid.length ? "fail" : "pass", { total: links.length, invalid });
|
|
8932
|
+
if (tpl.kind === "marketing") {
|
|
8933
|
+
push("unsubscribe_link", rendered.html.includes(unsubscribeUrl) ? "pass" : "fail", {
|
|
8934
|
+
hint: "a marketing template must reference {{unsubscribeUrl}} in its body"
|
|
8935
|
+
});
|
|
8936
|
+
if (mailer.config.senderAddress) {
|
|
8937
|
+
push("sender_address", rendered.html.includes(mailer.config.senderAddress) ? "pass" : "fail", {
|
|
8938
|
+
hint: "CAN-SPAM: reference {{senderAddress}} in the footer"
|
|
8939
|
+
});
|
|
8940
|
+
}
|
|
8941
|
+
}
|
|
8942
|
+
const text = rendered.plainText.trim();
|
|
8943
|
+
push("plain_text", text.length === 0 ? "fail" : text.length < 40 ? "warn" : "pass", { length: text.length });
|
|
8944
|
+
const subject = rendered.subject.trim();
|
|
8945
|
+
push("subject", subject.length === 0 ? "fail" : subject.length > 78 ? "warn" : "pass", { length: subject.length });
|
|
8946
|
+
if (mailer.config.senderDomains && Object.keys(mailer.config.senderDomains).length > 0) {
|
|
8947
|
+
const v = validateSenderDomain(rendered.fromEmail, tpl.kind, mailer.config.senderDomains);
|
|
8948
|
+
push("from_domain", v.ok ? "pass" : "fail", v.ok ? { fromEmail: rendered.fromEmail } : { fromEmail: rendered.fromEmail, code: v.code, reason: v.reason });
|
|
8949
|
+
}
|
|
8950
|
+
const bytes = Buffer.byteLength(rendered.html, "utf8");
|
|
8951
|
+
push("html_size", bytes > GMAIL_CLIP_BYTES ? "warn" : "pass", { bytes, clipAt: GMAIL_CLIP_BYTES });
|
|
8952
|
+
const lint = lintTemplate(
|
|
8953
|
+
{
|
|
8954
|
+
subject: tpl.subject,
|
|
8955
|
+
preheader: tpl.preheader,
|
|
8956
|
+
mjml: tpl.body.mjml ?? "",
|
|
8957
|
+
editorJson: tpl.body.editorJson ?? void 0,
|
|
8958
|
+
html: tpl.body.html,
|
|
8959
|
+
plainText: tpl.body.plainText,
|
|
8960
|
+
kind: tpl.kind,
|
|
8961
|
+
fromEmail: tpl.fromEmail
|
|
8962
|
+
},
|
|
8963
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: opts.varsSchema ?? null }
|
|
8964
|
+
);
|
|
8965
|
+
push("lint", lint.errors.length ? "fail" : lint.warnings.length ? "warn" : "pass", {
|
|
8966
|
+
errors: lint.errors.map((i) => ({ rule: i.rule, message: i.message })),
|
|
8967
|
+
warnings: lint.warnings.map((i) => i.rule)
|
|
8968
|
+
});
|
|
8969
|
+
return finish(rendered, links);
|
|
8970
|
+
}
|
|
8971
|
+
async function renderForContact(mailer, tpl, contact, opts) {
|
|
8972
|
+
let resolved;
|
|
8973
|
+
try {
|
|
8974
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
8975
|
+
reason: opts.reason,
|
|
8976
|
+
templateSlug: tpl.slug,
|
|
8977
|
+
eventProperties: opts.eventProperties
|
|
8978
|
+
});
|
|
8979
|
+
} catch (err) {
|
|
8980
|
+
throw new Error(`varsAdapter.resolve threw: ${String(err?.message ?? err)}`);
|
|
8981
|
+
}
|
|
8982
|
+
const unsubscribeUrl = unsubscribeUrlFor(mailer, contact.email);
|
|
8983
|
+
const context = {
|
|
8984
|
+
...resolved,
|
|
8985
|
+
contact,
|
|
8986
|
+
vars: opts.vars ?? {},
|
|
8987
|
+
event: opts.eventProperties ?? {},
|
|
8988
|
+
unsubscribeUrl,
|
|
8989
|
+
senderAddress: mailer.config.senderAddress
|
|
8990
|
+
};
|
|
8991
|
+
const rendered = await renderTemplate(tpl, context, { helpers: mailer.config.handlebarsHelpers });
|
|
8992
|
+
return { rendered, resolved, unsubscribeUrl, context };
|
|
8993
|
+
}
|
|
8994
|
+
function unsubscribeUrlFor(mailer, email) {
|
|
8995
|
+
const expiresAt = new Date(Date.now() + mailer.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
8996
|
+
const token = signUnsubscribeToken({ email, scope: "marketing", expiresAt }, mailer.config.unsubscribeSecret);
|
|
8997
|
+
return `${mailer.config.publicUrl}/m/unsub/${token}`;
|
|
8998
|
+
}
|
|
8999
|
+
function extractLinks(html) {
|
|
9000
|
+
const out = [];
|
|
9001
|
+
for (const m of html.matchAll(HREF_RE)) {
|
|
9002
|
+
const href = (m[1] ?? m[2] ?? "").trim();
|
|
9003
|
+
if (!href || href.startsWith("#")) continue;
|
|
9004
|
+
out.push(href);
|
|
9005
|
+
}
|
|
9006
|
+
return out;
|
|
9007
|
+
}
|
|
9008
|
+
var BUILTIN_HELPERS = /* @__PURE__ */ new Set([
|
|
9009
|
+
"eq",
|
|
9010
|
+
"ne",
|
|
9011
|
+
"gt",
|
|
9012
|
+
"lt",
|
|
9013
|
+
"gte",
|
|
9014
|
+
"lte",
|
|
9015
|
+
"and",
|
|
9016
|
+
"or",
|
|
9017
|
+
"not",
|
|
9018
|
+
"formatDate",
|
|
9019
|
+
"formatNumber",
|
|
9020
|
+
"formatCurrency",
|
|
9021
|
+
"pluralize",
|
|
9022
|
+
"if",
|
|
9023
|
+
"unless",
|
|
9024
|
+
"each",
|
|
9025
|
+
"with",
|
|
9026
|
+
"else",
|
|
9027
|
+
"lookup",
|
|
9028
|
+
"log",
|
|
9029
|
+
"this",
|
|
9030
|
+
"true",
|
|
9031
|
+
"false",
|
|
9032
|
+
"null",
|
|
9033
|
+
"undefined"
|
|
9034
|
+
]);
|
|
9035
|
+
var MUSTACHE_RE = /\{\{\{?([^{}]*)\}\}\}?/g;
|
|
9036
|
+
var BLOCK_SCOPE_RE = /\{\{#(each|with)\b[\s\S]*?\{\{\/\1\}\}/g;
|
|
9037
|
+
var HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
9038
|
+
var PATH_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
|
|
9039
|
+
function referencedPaths(source, helperNames = []) {
|
|
9040
|
+
const helpers = /* @__PURE__ */ new Set([...BUILTIN_HELPERS, ...helperNames]);
|
|
9041
|
+
const out = /* @__PURE__ */ new Set();
|
|
9042
|
+
const scanned = source.replace(HTML_COMMENT_RE, "").replace(BLOCK_SCOPE_RE, "");
|
|
9043
|
+
for (const m of scanned.matchAll(MUSTACHE_RE)) {
|
|
9044
|
+
let expr = (m[1] ?? "").trim();
|
|
9045
|
+
if (!expr || expr.startsWith("!")) continue;
|
|
9046
|
+
expr = expr.replace(/^[#/^]\s*/, "").replace(/^else\b\s*/, "");
|
|
9047
|
+
for (let tok of expr.split(/[\s()]+/)) {
|
|
9048
|
+
if (!tok) continue;
|
|
9049
|
+
const eq = tok.indexOf("=");
|
|
9050
|
+
if (eq > 0) tok = tok.slice(eq + 1);
|
|
9051
|
+
if (/^['"]/.test(tok) || /^-?\d/.test(tok)) continue;
|
|
9052
|
+
if (tok.startsWith("@") || tok.startsWith("../") || tok.startsWith("this.")) continue;
|
|
9053
|
+
if (helpers.has(tok) || !PATH_RE.test(tok)) continue;
|
|
9054
|
+
out.add(tok);
|
|
9055
|
+
}
|
|
9056
|
+
}
|
|
9057
|
+
return [...out];
|
|
9058
|
+
}
|
|
9059
|
+
function lookupPath(ctx, path3) {
|
|
9060
|
+
let cur = ctx;
|
|
9061
|
+
for (const part of path3.split(".")) {
|
|
9062
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
9063
|
+
cur = cur[part];
|
|
9064
|
+
}
|
|
9065
|
+
return cur;
|
|
9066
|
+
}
|
|
9067
|
+
function testContactMatcher(spec) {
|
|
9068
|
+
if (!spec) return null;
|
|
9069
|
+
if (spec instanceof RegExp) return (email) => spec.test(email);
|
|
9070
|
+
if (typeof spec === "function") return (email) => !!spec(email);
|
|
9071
|
+
return null;
|
|
9072
|
+
}
|
|
9073
|
+
function bearerAuth(tokens) {
|
|
9074
|
+
const hashed = tokens.map((t) => ({ hash: crypto2__default.default.createHash("sha256").update(t.token).digest(), actor: t.actor }));
|
|
9075
|
+
return (req, res, next) => {
|
|
9076
|
+
const header = String(req.headers.authorization ?? "");
|
|
9077
|
+
const m = /^Bearer\s+(\S+)\s*$/i.exec(header);
|
|
9078
|
+
if (!m) {
|
|
9079
|
+
return res.status(401).json({ error: "unauthorized", message: "send Authorization: Bearer <token>" });
|
|
9080
|
+
}
|
|
9081
|
+
const presented = crypto2__default.default.createHash("sha256").update(m[1] ?? "").digest();
|
|
9082
|
+
const match = hashed.find((h) => crypto2__default.default.timingSafeEqual(h.hash, presented));
|
|
9083
|
+
if (!match) return res.status(401).json({ error: "unauthorized", message: "unknown token" });
|
|
9084
|
+
req.actor = match.actor;
|
|
9085
|
+
next();
|
|
9086
|
+
};
|
|
9087
|
+
}
|
|
9088
|
+
async function advanceOnce(runId, ctx, actor, c) {
|
|
9089
|
+
for (let hop = 0; hop < 10; hop += 1) {
|
|
9090
|
+
const run = await c.flowRuns.findOne({ _id: runId });
|
|
9091
|
+
if (!run || run.status !== "active") return false;
|
|
9092
|
+
if (run.nextActionAt && run.nextActionAt.getTime() > Date.now()) {
|
|
9093
|
+
await c.flowRuns.updateOne(
|
|
9094
|
+
{ _id: runId, status: "active" },
|
|
9095
|
+
{
|
|
9096
|
+
$set: { nextActionAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() },
|
|
9097
|
+
$push: {
|
|
9098
|
+
history: {
|
|
9099
|
+
stepIndex: Math.max(0, run.currentStepIndex - 1),
|
|
9100
|
+
action: "wait_completed",
|
|
9101
|
+
at: /* @__PURE__ */ new Date(),
|
|
9102
|
+
details: { forcedBy: actor, scheduledFor: run.nextActionAt }
|
|
9103
|
+
}
|
|
9104
|
+
}
|
|
9105
|
+
}
|
|
9106
|
+
);
|
|
9107
|
+
}
|
|
9108
|
+
const before = (await c.flowRuns.findOne({ _id: runId }))?.history.length ?? 0;
|
|
9109
|
+
await processOneRunStep(runId, ctx);
|
|
9110
|
+
const after = await c.flowRuns.findOne({ _id: runId });
|
|
9111
|
+
if (!after) return false;
|
|
9112
|
+
const last = after.history[after.history.length - 1];
|
|
9113
|
+
const progressed = after.history.length > before;
|
|
9114
|
+
if (progressed && last?.action === "wait_started" && after.status === "active") continue;
|
|
9115
|
+
return after.status === "active" && progressed;
|
|
9116
|
+
}
|
|
9117
|
+
return false;
|
|
9118
|
+
}
|
|
9119
|
+
var WAIT_TARGETS = /* @__PURE__ */ new Set(["sent", "delivered", "opened", "clicked", "terminal"]);
|
|
9120
|
+
function waitTargetReached(send, target) {
|
|
9121
|
+
switch (target) {
|
|
9122
|
+
case "sent":
|
|
9123
|
+
return send.status === "sent" || send.status === "delivered" || !!send.sentAt;
|
|
9124
|
+
case "delivered":
|
|
9125
|
+
return send.status === "delivered" || !!send.deliveredAt;
|
|
9126
|
+
case "opened":
|
|
9127
|
+
return !!send.openedAt;
|
|
9128
|
+
case "clicked":
|
|
9129
|
+
return !!send.firstClickAt;
|
|
9130
|
+
case "terminal":
|
|
9131
|
+
return ["delivered", "bounced", "failed", "suppressed", "cancelled", "complained"].includes(send.status);
|
|
9132
|
+
default:
|
|
9133
|
+
return false;
|
|
9134
|
+
}
|
|
9135
|
+
}
|
|
9136
|
+
function sendSummary(s) {
|
|
9137
|
+
return {
|
|
9138
|
+
id: String(s._id),
|
|
9139
|
+
templateSlug: s.templateSlug,
|
|
9140
|
+
externalId: s.externalId,
|
|
9141
|
+
email: s.emailAtSend,
|
|
9142
|
+
kind: s.kind,
|
|
9143
|
+
status: s.status,
|
|
9144
|
+
provider: s.provider,
|
|
9145
|
+
providerMessageId: s.providerMessageId,
|
|
9146
|
+
subject: s.subject,
|
|
9147
|
+
errorMessage: s.errorMessage,
|
|
9148
|
+
flowRunId: s.flowRunId ? String(s.flowRunId) : null,
|
|
9149
|
+
queuedAt: s.queuedAt,
|
|
9150
|
+
sentAt: s.sentAt,
|
|
9151
|
+
deliveredAt: s.deliveredAt,
|
|
9152
|
+
openedAt: s.openedAt,
|
|
9153
|
+
firstClickAt: s.firstClickAt,
|
|
9154
|
+
bounceType: s.bounceType
|
|
9155
|
+
};
|
|
9156
|
+
}
|
|
9157
|
+
function runSummary(r) {
|
|
9158
|
+
return {
|
|
9159
|
+
id: String(r._id),
|
|
9160
|
+
flowSlug: r.flowSlug,
|
|
9161
|
+
flowVersion: r.flowVersion,
|
|
9162
|
+
externalId: r.externalId,
|
|
9163
|
+
email: r.emailAtEntry,
|
|
9164
|
+
status: r.status,
|
|
9165
|
+
currentStepIndex: r.currentStepIndex,
|
|
9166
|
+
currentBranchPath: r.currentBranchPath,
|
|
9167
|
+
nextActionAt: r.nextActionAt,
|
|
9168
|
+
enteredAt: r.enteredAt,
|
|
9169
|
+
exitedAt: r.exitedAt,
|
|
9170
|
+
exitReason: r.exitReason,
|
|
9171
|
+
triggerEvent: r.triggerEvent ?? null,
|
|
9172
|
+
history: r.history
|
|
9173
|
+
};
|
|
9174
|
+
}
|
|
9175
|
+
function objectOrUndefined(v) {
|
|
9176
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
9177
|
+
}
|
|
9178
|
+
function sleep2(ms) {
|
|
9179
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
9180
|
+
}
|
|
9181
|
+
function wrap2(fn) {
|
|
9182
|
+
return (req, res, next) => {
|
|
9183
|
+
Promise.resolve(fn(req, res)).catch(next);
|
|
9184
|
+
};
|
|
9185
|
+
}
|
|
9186
|
+
var ENDPOINTS = [
|
|
9187
|
+
{ method: "GET", path: "/", summary: "This listing, the package version and the actor behind the token." },
|
|
9188
|
+
{ method: "*", path: "/api/*", summary: "The full admin JSON API (flows, templates, contacts, sends, health, setup-status, \u2026) with this token as the actor." },
|
|
9189
|
+
{ 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." },
|
|
9190
|
+
{ method: "POST", path: "/templates/verify-all", summary: "Verify every template (or {slugs}) for each of {contactIds}; a matrix of pass/fail." },
|
|
9191
|
+
{ 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." },
|
|
9192
|
+
{ 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 },
|
|
9193
|
+
{ 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." },
|
|
9194
|
+
{ method: "POST", path: "/sends/:id/dispatch", summary: "Dispatch a queued send now (test contacts only).", testContactsOnly: true },
|
|
9195
|
+
{ 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." },
|
|
9196
|
+
{ 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}." },
|
|
9197
|
+
{ method: "POST", path: "/flows/:slug/disarm", summary: "Disable the flow (pause). In-flight runs continue." },
|
|
9198
|
+
{ method: "POST", path: "/flows/:slug/gate", summary: "Publish a canary version whose first step exits anyone without {tag}." },
|
|
9199
|
+
{ method: "POST", path: "/flows/:slug/ungate", summary: "Restore the newest ungated version." },
|
|
9200
|
+
{ method: "GET", path: "/runs?externalId=&flowSlug=&status=&limit=", summary: "List flow runs." },
|
|
9201
|
+
{ method: "GET", path: "/runs/:id", summary: "One run with its history and sends." },
|
|
9202
|
+
{ 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 },
|
|
9203
|
+
{ method: "POST", path: "/runs/:id/cancel", summary: "Exit an active run and cancel its queued sends." },
|
|
9204
|
+
{ method: "POST", path: "/events", summary: "Fire {name} for a test contact {externalId} with {properties} and optional {dedupeKey}.", testContactsOnly: true },
|
|
9205
|
+
{ method: "GET", path: "/contacts/:externalId", summary: "Contact with subscription, suppressions, recent events, sends and runs." },
|
|
9206
|
+
{ method: "GET", path: "/contacts/by-email/:email", summary: "Same, looked up by email." },
|
|
9207
|
+
{ 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 },
|
|
9208
|
+
{ method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
|
|
9209
|
+
{ method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
|
|
9210
|
+
{ 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 },
|
|
9211
|
+
{ method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
|
|
9212
|
+
{ method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
|
|
9213
|
+
{ method: "GET", path: "/status", summary: "One document with setup checks, health, every flow (enabled, version, watermark, gate, active runs), every template, and 24h counts." }
|
|
9214
|
+
];
|
|
9215
|
+
var ALLOWED_EXTENSIONS = [".zip", ".gz", ".xml"];
|
|
9216
|
+
var DEFAULT_PATH = "/inbound/dmarc";
|
|
9217
|
+
var DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
9218
|
+
var DEFAULT_MAX_FILES = 10;
|
|
9219
|
+
function mountDmarcInbound(router, mailer, opts, logger) {
|
|
9220
|
+
const secret = opts?.secret;
|
|
9221
|
+
if (!opts || typeof secret !== "string" || secret.length === 0) return null;
|
|
9222
|
+
const routePath = opts.path ?? DEFAULT_PATH;
|
|
9223
|
+
const parseInbound = opts.parseInbound ?? sendgridInboundParser;
|
|
9224
|
+
const allowedDomains = normalizeDomains(opts.allowedDomains ?? deriveSenderDomains(mailer));
|
|
9225
|
+
if (allowedDomains.size === 0) {
|
|
9226
|
+
logger.warn?.(
|
|
9227
|
+
{ path: routePath },
|
|
9228
|
+
"mailery: DMARC inbound route mounted without a domain allowlist \u2014 set senderDomains (or dmarcInbound.allowedDomains) so a leaked secret cannot inject reports for arbitrary domains"
|
|
9229
|
+
);
|
|
9230
|
+
}
|
|
9231
|
+
const upload = multer__default.default({
|
|
9232
|
+
storage: multer__default.default.memoryStorage(),
|
|
9233
|
+
limits: {
|
|
9234
|
+
fileSize: opts.maxFileSizeBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
9235
|
+
files: opts.maxFiles ?? DEFAULT_MAX_FILES,
|
|
9236
|
+
// Inbound Parse sends the whole email as form fields (headers, text,
|
|
9237
|
+
// html, ...). We read none of them, but they still have to be bounded.
|
|
9238
|
+
fields: 40,
|
|
9239
|
+
fieldSize: 1024 * 1024
|
|
9240
|
+
}
|
|
9241
|
+
});
|
|
9242
|
+
router.post(
|
|
9243
|
+
routePath,
|
|
9244
|
+
wrap(logger, (req, res, next) => {
|
|
9245
|
+
if (!isAuthorized(req, secret)) {
|
|
9246
|
+
logger.warn?.(
|
|
9247
|
+
{ path: routePath, ip: req.ip },
|
|
9248
|
+
"mailery: DMARC inbound request rejected \u2014 bad or missing shared secret"
|
|
9249
|
+
);
|
|
9250
|
+
res.status(401).json({ error: "unauthorized" });
|
|
9251
|
+
return;
|
|
9252
|
+
}
|
|
9253
|
+
next();
|
|
9254
|
+
}),
|
|
9255
|
+
(req, res, next) => {
|
|
9256
|
+
upload.any()(req, res, (err) => {
|
|
9257
|
+
if (!err) return next();
|
|
9258
|
+
const code = err?.code;
|
|
9259
|
+
const status = code === "LIMIT_FILE_SIZE" || code === "LIMIT_FILE_COUNT" ? 413 : 400;
|
|
9260
|
+
logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound upload rejected");
|
|
9261
|
+
res.status(status).json({ error: "upload_rejected", message: String(code ?? "malformed_multipart") });
|
|
9262
|
+
});
|
|
9263
|
+
},
|
|
9264
|
+
wrap(logger, async (req, res) => {
|
|
9265
|
+
let attachments;
|
|
9266
|
+
try {
|
|
9267
|
+
attachments = parseInbound(req);
|
|
9268
|
+
} catch (err) {
|
|
9269
|
+
logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound payload unreadable");
|
|
9270
|
+
return res.status(400).json({ error: "bad_payload" });
|
|
9271
|
+
}
|
|
9272
|
+
const candidates = attachments.filter((a) => hasAllowedExtension(a.filename));
|
|
9273
|
+
if (candidates.length === 0) {
|
|
9274
|
+
logger.info?.(
|
|
9275
|
+
{ path: routePath, attachments: attachments.length },
|
|
9276
|
+
"mailery: DMARC inbound message carried no report attachment \u2014 ignored"
|
|
9277
|
+
);
|
|
9278
|
+
return res.status(200).json({ ok: true, ingested: 0, ignored: attachments.length });
|
|
9279
|
+
}
|
|
9280
|
+
const ctx = mailer.getRunnerContext();
|
|
9281
|
+
const allowDomain = allowedDomains.size === 0 ? void 0 : (d) => allowedDomains.has(d.toLowerCase());
|
|
9282
|
+
const ingested = [];
|
|
9283
|
+
const rejected = [];
|
|
9284
|
+
for (const file of candidates) {
|
|
9285
|
+
try {
|
|
9286
|
+
const result = await ingestDmarcAttachment(ctx, file.buffer, file.filename, { allowDomain });
|
|
9287
|
+
ingested.push({ reportId: result.reportId, domain: result.domain, duplicate: result.duplicate });
|
|
9288
|
+
} catch (err) {
|
|
9289
|
+
const message = String(err?.message ?? err);
|
|
9290
|
+
rejected.push({ filename: file.filename, message });
|
|
9291
|
+
logger.warn?.(
|
|
9292
|
+
{ path: routePath, filename: file.filename, err },
|
|
9293
|
+
"mailery: DMARC inbound attachment rejected"
|
|
9294
|
+
);
|
|
9295
|
+
}
|
|
9296
|
+
}
|
|
9297
|
+
if (ingested.length === 0) {
|
|
9298
|
+
return res.status(400).json({ error: "ingest_failed", rejected });
|
|
9299
|
+
}
|
|
9300
|
+
return res.status(200).json({ ok: true, ingested, rejected });
|
|
9301
|
+
})
|
|
9302
|
+
);
|
|
9303
|
+
return routePath;
|
|
9304
|
+
}
|
|
9305
|
+
function isAuthorized(req, secret) {
|
|
9306
|
+
const presented = extractPresentedSecret(req);
|
|
9307
|
+
if (presented === null) return false;
|
|
9308
|
+
return constantTimeEquals(presented, secret);
|
|
9309
|
+
}
|
|
9310
|
+
function extractPresentedSecret(req) {
|
|
9311
|
+
const header = req.headers.authorization;
|
|
9312
|
+
if (typeof header !== "string") return null;
|
|
9313
|
+
const [scheme, ...rest] = header.split(" ");
|
|
9314
|
+
const value = rest.join(" ").trim();
|
|
9315
|
+
if (!scheme || value === "") return null;
|
|
9316
|
+
switch (scheme.toLowerCase()) {
|
|
9317
|
+
case "basic": {
|
|
9318
|
+
let decoded;
|
|
9319
|
+
try {
|
|
9320
|
+
decoded = Buffer.from(value, "base64").toString("utf8");
|
|
9321
|
+
} catch {
|
|
9322
|
+
return null;
|
|
9323
|
+
}
|
|
9324
|
+
const sep = decoded.indexOf(":");
|
|
9325
|
+
return sep === -1 ? null : decoded.slice(sep + 1);
|
|
9326
|
+
}
|
|
9327
|
+
case "bearer":
|
|
9328
|
+
return value;
|
|
9329
|
+
default:
|
|
9330
|
+
return null;
|
|
9331
|
+
}
|
|
9332
|
+
}
|
|
9333
|
+
function constantTimeEquals(a, b) {
|
|
9334
|
+
const ha = crypto2.createHash("sha256").update(a, "utf8").digest();
|
|
9335
|
+
const hb = crypto2.createHash("sha256").update(b, "utf8").digest();
|
|
9336
|
+
return crypto2.timingSafeEqual(ha, hb);
|
|
9337
|
+
}
|
|
9338
|
+
var sendgridInboundParser = (req) => {
|
|
9339
|
+
const files = req.files;
|
|
9340
|
+
if (!Array.isArray(files)) return [];
|
|
9341
|
+
return files.filter((f) => Boolean(f) && Buffer.isBuffer(f.buffer)).map((f) => ({ filename: safeFilename(f.originalname), buffer: f.buffer }));
|
|
9342
|
+
};
|
|
9343
|
+
function safeFilename(raw) {
|
|
9344
|
+
if (typeof raw !== "string") return "";
|
|
9345
|
+
return raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/[\\/]/g, "_").slice(0, 255);
|
|
9346
|
+
}
|
|
9347
|
+
function hasAllowedExtension(filename) {
|
|
9348
|
+
const lower = filename.toLowerCase();
|
|
9349
|
+
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
9350
|
+
}
|
|
9351
|
+
function normalizeDomains(domains) {
|
|
9352
|
+
const out = /* @__PURE__ */ new Set();
|
|
9353
|
+
for (const d of domains) {
|
|
9354
|
+
if (typeof d !== "string") continue;
|
|
9355
|
+
const trimmed = d.trim().toLowerCase().replace(/^\.+/, "");
|
|
9356
|
+
if (trimmed) out.add(trimmed);
|
|
9357
|
+
}
|
|
9358
|
+
return out;
|
|
9359
|
+
}
|
|
9360
|
+
function deriveSenderDomains(mailer) {
|
|
9361
|
+
const out = [];
|
|
9362
|
+
const push = (domain) => {
|
|
9363
|
+
if (!domain) return;
|
|
9364
|
+
out.push(domain);
|
|
9365
|
+
const labels = domain.split(".");
|
|
9366
|
+
if (labels.length > 2) out.push(labels.slice(-2).join("."));
|
|
9367
|
+
};
|
|
9368
|
+
for (const domain of Object.keys(mailer.config.senderDomains ?? {})) push(domain.toLowerCase());
|
|
9369
|
+
push(emailDomain(mailer.config.fromDefaults?.email));
|
|
9370
|
+
push(emailDomain(mailer.config.transactionalFromDefaults?.email));
|
|
9371
|
+
push(emailDomain(mailer.config.senderAddress));
|
|
9372
|
+
return out;
|
|
9373
|
+
}
|
|
9374
|
+
function emailDomain(email) {
|
|
9375
|
+
if (typeof email !== "string") return void 0;
|
|
9376
|
+
const at = email.lastIndexOf("@");
|
|
9377
|
+
if (at === -1) return void 0;
|
|
9378
|
+
const domain = email.slice(at + 1).trim().toLowerCase();
|
|
9379
|
+
return domain || void 0;
|
|
9380
|
+
}
|
|
9381
|
+
|
|
9382
|
+
// src/server/api/public.ts
|
|
9383
|
+
var PIXEL = Buffer.from(
|
|
9384
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
9385
|
+
"base64"
|
|
9386
|
+
);
|
|
9387
|
+
function createPublicRouter(mailer, opts = {}) {
|
|
9388
|
+
const router = express.Router();
|
|
9389
|
+
const logger = opts.logger ?? consoleRouteLogger;
|
|
9390
|
+
const pendingUnsubsPath = opts.pendingUnsubsPath ?? mailer.config.pendingUnsubsPath ?? null;
|
|
9391
|
+
if (opts.pendingUnsubsPath && !mailer.config.pendingUnsubsPath) {
|
|
9392
|
+
logger.warn?.(
|
|
9393
|
+
{ path: opts.pendingUnsubsPath },
|
|
9394
|
+
"mailery: pendingUnsubsPath set on the public router but not in MailerConfig \u2014 the tick drain will not replay this journal"
|
|
9395
|
+
);
|
|
9396
|
+
}
|
|
9397
|
+
if (!pendingUnsubsPath) {
|
|
9398
|
+
logger.warn?.(
|
|
9399
|
+
{},
|
|
9400
|
+
"mailery: no pendingUnsubsPath configured \u2014 POST /unsub answers 503 when Mongo is unreachable instead of journaling the opt-out"
|
|
9401
|
+
);
|
|
9402
|
+
}
|
|
9403
|
+
const dmarcInboundPath = mountDmarcInbound(router, mailer, opts.dmarcInbound, logger);
|
|
9404
|
+
if (dmarcInboundPath) {
|
|
9405
|
+
logger.info?.(
|
|
9406
|
+
{ path: dmarcInboundPath },
|
|
9407
|
+
"mailery: DMARC inbound route mounted (shared-secret auth; SendGrid Inbound Parse does not sign payloads)"
|
|
9408
|
+
);
|
|
9409
|
+
}
|
|
9410
|
+
router.use(
|
|
9411
|
+
"/webhooks",
|
|
9412
|
+
express__default.default.json({
|
|
9413
|
+
limit: "5mb",
|
|
9414
|
+
verify: (req, _res, buf) => {
|
|
9415
|
+
req.rawBody = buf;
|
|
9416
|
+
}
|
|
9417
|
+
})
|
|
9418
|
+
);
|
|
9419
|
+
router.use("/unsub", express__default.default.urlencoded({ extended: false }));
|
|
9420
|
+
router.get("/open/:sendId.png", wrap(logger, async (req, res) => {
|
|
9421
|
+
res.setHeader("Content-Type", "image/png");
|
|
9422
|
+
res.setHeader("Content-Length", String(PIXEL.length));
|
|
9423
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
9424
|
+
res.status(200).end(PIXEL);
|
|
9425
|
+
const parsed = splitOpenParam(req.params.sendId);
|
|
9426
|
+
if (!parsed) return;
|
|
9427
|
+
const { id, sig } = parsed;
|
|
9428
|
+
if (!mongodb.ObjectId.isValid(id)) return;
|
|
9429
|
+
const sendId = new mongodb.ObjectId(id);
|
|
9430
|
+
const verdict = checkTrackingSignature({
|
|
9431
|
+
scope: "open",
|
|
9432
|
+
params: { sendId: id },
|
|
9433
|
+
sig,
|
|
9434
|
+
secret: mailer.config.unsubscribeSecret,
|
|
9435
|
+
requireSigned: mailer.config.requireSignedTrackingUrls,
|
|
9436
|
+
logger,
|
|
9437
|
+
fields: { sendId: id }
|
|
9438
|
+
});
|
|
9439
|
+
if (verdict === "rejected") return;
|
|
9440
|
+
try {
|
|
9441
|
+
const send = await mailer.collections.sends.findOne(
|
|
9442
|
+
{ _id: sendId },
|
|
9443
|
+
{ projection: { openedAt: 1, queuedAt: 1 } }
|
|
9444
|
+
);
|
|
9445
|
+
if (!send) return;
|
|
9446
|
+
if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
|
|
9447
|
+
logger.warn?.({ sendId: id }, "mailery: open ignored \u2014 tracking URL past trackingUrlLifetimeDays");
|
|
9448
|
+
return;
|
|
9449
|
+
}
|
|
9450
|
+
const now = /* @__PURE__ */ new Date();
|
|
9451
|
+
await mailer.collections.sends.updateOne(
|
|
9452
|
+
{ _id: sendId },
|
|
9453
|
+
{
|
|
9454
|
+
$set: {
|
|
9455
|
+
openedAt: send.openedAt ?? now,
|
|
9456
|
+
status: "delivered"
|
|
9457
|
+
},
|
|
9458
|
+
$inc: { openCount: 1 },
|
|
9459
|
+
// Per-open user agent, so `hasOpenedExcludingBots` has a signal to
|
|
9460
|
+
// filter on. Capped: an open pixel can be re-fetched indefinitely
|
|
9461
|
+
// (every time the recipient re-opens the mail) and an unbounded
|
|
9462
|
+
// array would eventually run the send document into the 16MB limit.
|
|
9463
|
+
$push: {
|
|
9464
|
+
opens: {
|
|
9465
|
+
$each: [{ openedAt: now, userAgent: requestUserAgent(req) }],
|
|
9466
|
+
$slice: -MAX_TRACKED_OPENS
|
|
9467
|
+
}
|
|
9468
|
+
}
|
|
9469
|
+
}
|
|
9470
|
+
);
|
|
9471
|
+
} catch (err) {
|
|
9472
|
+
logger.error?.({ err, sendId: id }, "mailery: open pixel update failed");
|
|
9473
|
+
}
|
|
9474
|
+
}));
|
|
9475
|
+
router.get("/click/:sendId/:linkId{/:sig}", wrap(logger, async (req, res) => {
|
|
9476
|
+
const { sendId: sendIdStr, linkId, sig } = req.params;
|
|
9477
|
+
if (!mongodb.ObjectId.isValid(sendIdStr)) return res.status(400).end();
|
|
9478
|
+
const sendId = new mongodb.ObjectId(sendIdStr);
|
|
9479
|
+
const verdict = checkTrackingSignature({
|
|
9480
|
+
scope: "click",
|
|
9481
|
+
params: { sendId: sendIdStr, linkId },
|
|
9482
|
+
sig,
|
|
9483
|
+
secret: mailer.config.unsubscribeSecret,
|
|
9484
|
+
requireSigned: mailer.config.requireSignedTrackingUrls,
|
|
9485
|
+
logger,
|
|
9486
|
+
fields: { sendId: sendIdStr, linkId }
|
|
9487
|
+
});
|
|
9488
|
+
if (verdict === "rejected") return res.status(404).end();
|
|
9489
|
+
const send = await mailer.collections.sends.findOne(
|
|
9490
|
+
{ _id: sendId },
|
|
9491
|
+
{ projection: { links: 1, firstClickAt: 1, queuedAt: 1 } }
|
|
9492
|
+
);
|
|
9493
|
+
if (!send) return res.status(404).end();
|
|
9494
|
+
if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
|
|
9495
|
+
logger.warn?.(
|
|
9496
|
+
{ sendId: sendIdStr, linkId },
|
|
9497
|
+
"mailery: click rejected \u2014 tracking URL past trackingUrlLifetimeDays"
|
|
9498
|
+
);
|
|
9499
|
+
return res.status(404).end();
|
|
9500
|
+
}
|
|
9501
|
+
const link = (send.links ?? []).find((l) => l.linkId === linkId);
|
|
9502
|
+
if (!link) return res.status(404).end();
|
|
9503
|
+
if (!isSafeRedirectTarget(link.url)) {
|
|
9504
|
+
logger.warn?.(
|
|
9505
|
+
{ sendId: sendIdStr, linkId, url: link.url },
|
|
9506
|
+
"mailery: click redirect blocked \u2014 disallowed URL scheme"
|
|
9507
|
+
);
|
|
9508
|
+
return res.status(400).type("html").send("<!doctype html><html><body><p>This link cannot be opened.</p></body></html>");
|
|
9509
|
+
}
|
|
9510
|
+
res.redirect(302, link.url);
|
|
7477
9511
|
try {
|
|
7478
9512
|
await mailer.collections.sends.updateOne(
|
|
7479
9513
|
{ _id: sendId },
|
|
@@ -7484,16 +9518,19 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7484
9518
|
clickedLinks: {
|
|
7485
9519
|
url: link.url,
|
|
7486
9520
|
linkId,
|
|
7487
|
-
clickedAt: /* @__PURE__ */ new Date()
|
|
9521
|
+
clickedAt: /* @__PURE__ */ new Date(),
|
|
9522
|
+
// The bot filter has always read `clickedLinks[].userAgent`; until
|
|
9523
|
+
// now nothing ever wrote it, so every click scored as human.
|
|
9524
|
+
userAgent: requestUserAgent(req)
|
|
7488
9525
|
}
|
|
7489
9526
|
}
|
|
7490
9527
|
}
|
|
7491
9528
|
);
|
|
7492
9529
|
} catch (err) {
|
|
7493
|
-
|
|
9530
|
+
logger.error?.({ err, sendId: sendIdStr, linkId }, "mailery: click recording failed");
|
|
7494
9531
|
}
|
|
7495
|
-
});
|
|
7496
|
-
router.get("/unsub/:token", (req, res) => {
|
|
9532
|
+
}));
|
|
9533
|
+
router.get("/unsub/:token", wrap(logger, (req, res) => {
|
|
7497
9534
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7498
9535
|
if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
|
|
7499
9536
|
res.status(200).type("html").send(`<!doctype html>
|
|
@@ -7508,32 +9545,55 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7508
9545
|
<button type="submit">Unsubscribe</button>
|
|
7509
9546
|
</form>
|
|
7510
9547
|
</body></html>`);
|
|
7511
|
-
});
|
|
7512
|
-
router.post("/unsub/:token", async (req, res) => {
|
|
9548
|
+
}));
|
|
9549
|
+
router.post("/unsub/:token", wrap(logger, async (req, res) => {
|
|
7513
9550
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7514
9551
|
if (!decoded) {
|
|
7515
9552
|
res.status(200).end();
|
|
7516
9553
|
return;
|
|
7517
9554
|
}
|
|
7518
|
-
|
|
9555
|
+
const write = mailer.unsubscribe(decoded.email, {
|
|
9556
|
+
scope: decoded.scope,
|
|
9557
|
+
reason: "user_request",
|
|
9558
|
+
source: "one-click"
|
|
9559
|
+
});
|
|
9560
|
+
let dbError = null;
|
|
7519
9561
|
try {
|
|
7520
|
-
await mailer.
|
|
7521
|
-
scope: decoded.scope,
|
|
7522
|
-
reason: "user_request",
|
|
7523
|
-
source: "one-click"
|
|
7524
|
-
});
|
|
9562
|
+
await withTimeout(write, mailer.config.unsubscribeWriteTimeoutMs);
|
|
7525
9563
|
} catch (err) {
|
|
9564
|
+
dbError = err ?? new Error("unsubscribe write failed");
|
|
9565
|
+
void write.catch(() => {
|
|
9566
|
+
});
|
|
9567
|
+
}
|
|
9568
|
+
if (dbError) {
|
|
9569
|
+
if (!pendingUnsubsPath) {
|
|
9570
|
+
logger.error?.(
|
|
9571
|
+
{ err: dbError },
|
|
9572
|
+
"mailery: unsubscribe write failed and no pendingUnsubsPath is configured \u2014 answering 503"
|
|
9573
|
+
);
|
|
9574
|
+
return sendUnsubUnavailable(res);
|
|
9575
|
+
}
|
|
7526
9576
|
try {
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
9577
|
+
appendPendingUnsub(pendingUnsubsPath, {
|
|
9578
|
+
email: decoded.email,
|
|
9579
|
+
scope: decoded.scope,
|
|
9580
|
+
at: Date.now()
|
|
9581
|
+
});
|
|
9582
|
+
logger.warn?.(
|
|
9583
|
+
{ err: dbError, path: pendingUnsubsPath },
|
|
9584
|
+
"mailery: unsubscribe journaled to disk \u2014 will be replayed by the tick drain"
|
|
7530
9585
|
);
|
|
7531
9586
|
} catch (diskErr) {
|
|
7532
|
-
|
|
9587
|
+
logger.error?.(
|
|
9588
|
+
{ err: dbError, diskErr, path: pendingUnsubsPath },
|
|
9589
|
+
"mailery: unsub disk fallback failed \u2014 answering 503"
|
|
9590
|
+
);
|
|
9591
|
+
return sendUnsubUnavailable(res);
|
|
7533
9592
|
}
|
|
7534
9593
|
}
|
|
7535
|
-
|
|
7536
|
-
|
|
9594
|
+
res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
|
|
9595
|
+
}));
|
|
9596
|
+
router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
|
|
7537
9597
|
const token = req.params.token;
|
|
7538
9598
|
const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
|
|
7539
9599
|
if (!decoded) {
|
|
@@ -7561,10 +9621,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7561
9621
|
} catch {
|
|
7562
9622
|
}
|
|
7563
9623
|
return res.status(200).type("html").send("<!doctype html><html><body><p>Thanks \u2014 you're subscribed.</p></body></html>");
|
|
7564
|
-
});
|
|
7565
|
-
router.post("/webhooks/:provider", async (req, res) => {
|
|
9624
|
+
}));
|
|
9625
|
+
router.post("/webhooks/:provider", wrap(logger, async (req, res) => {
|
|
7566
9626
|
const providerName = req.params.provider;
|
|
7567
|
-
const provider = mailer.providers
|
|
9627
|
+
const provider = resolveProvider(mailer.providers, providerName);
|
|
7568
9628
|
if (!provider) return res.status(404).end();
|
|
7569
9629
|
const rawBody = req.rawBody;
|
|
7570
9630
|
if (!rawBody) return res.status(400).end();
|
|
@@ -7595,7 +9655,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7595
9655
|
{ upsert: true }
|
|
7596
9656
|
);
|
|
7597
9657
|
} catch (err) {
|
|
7598
|
-
|
|
9658
|
+
logger.error?.(
|
|
9659
|
+
{ err, provider: providerName, providerEventId: evt.providerEventId },
|
|
9660
|
+
"mailery: webhook dedupe insert failed"
|
|
9661
|
+
);
|
|
7599
9662
|
}
|
|
7600
9663
|
}
|
|
7601
9664
|
if (events.length > 0) {
|
|
@@ -7604,15 +9667,94 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7604
9667
|
} catch {
|
|
7605
9668
|
}
|
|
7606
9669
|
}
|
|
7607
|
-
});
|
|
9670
|
+
}));
|
|
7608
9671
|
return router;
|
|
7609
9672
|
}
|
|
9673
|
+
var MAX_TRACKED_OPENS = 50;
|
|
9674
|
+
var MAX_UA_LENGTH = 256;
|
|
9675
|
+
function requestUserAgent(req) {
|
|
9676
|
+
const ua = req.headers["user-agent"];
|
|
9677
|
+
if (typeof ua !== "string" || ua.trim() === "") return null;
|
|
9678
|
+
return ua.slice(0, MAX_UA_LENGTH);
|
|
9679
|
+
}
|
|
9680
|
+
function splitOpenParam(raw) {
|
|
9681
|
+
if (typeof raw !== "string") return null;
|
|
9682
|
+
const parts = raw.split(".");
|
|
9683
|
+
if (parts.length === 1) return { id: parts[0] };
|
|
9684
|
+
if (parts.length === 2) return { id: parts[0], sig: parts[1] };
|
|
9685
|
+
return null;
|
|
9686
|
+
}
|
|
9687
|
+
function checkTrackingSignature(args) {
|
|
9688
|
+
const { sig, logger, fields, scope } = args;
|
|
9689
|
+
if (sig === void 0 || sig === "") {
|
|
9690
|
+
if (args.requireSigned) {
|
|
9691
|
+
logger.warn?.(
|
|
9692
|
+
{ ...fields, scope },
|
|
9693
|
+
"mailery: unsigned tracking URL rejected (requireSignedTrackingUrls)"
|
|
9694
|
+
);
|
|
9695
|
+
return "rejected";
|
|
9696
|
+
}
|
|
9697
|
+
logger.info?.(
|
|
9698
|
+
{ ...fields, scope },
|
|
9699
|
+
"mailery: unsigned tracking URL accepted \u2014 legacy grace mode"
|
|
9700
|
+
);
|
|
9701
|
+
return "legacy";
|
|
9702
|
+
}
|
|
9703
|
+
if (!verifyTrackingToken(sig, scope, args.params, args.secret)) {
|
|
9704
|
+
logger.warn?.({ ...fields, scope }, "mailery: tracking URL signature invalid");
|
|
9705
|
+
return "rejected";
|
|
9706
|
+
}
|
|
9707
|
+
return "signed";
|
|
9708
|
+
}
|
|
9709
|
+
function isTrackingExpired(send, lifetimeDays, now = /* @__PURE__ */ new Date()) {
|
|
9710
|
+
if (!lifetimeDays || lifetimeDays <= 0) return false;
|
|
9711
|
+
const queuedAt = send.queuedAt;
|
|
9712
|
+
if (!(queuedAt instanceof Date) || Number.isNaN(queuedAt.getTime())) return false;
|
|
9713
|
+
return now.getTime() - queuedAt.getTime() > lifetimeDays * 24 * 60 * 60 * 1e3;
|
|
9714
|
+
}
|
|
7610
9715
|
function sendUnsubError(res, msg) {
|
|
7611
9716
|
return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
|
|
7612
9717
|
}
|
|
9718
|
+
function sendUnsubUnavailable(res) {
|
|
9719
|
+
return res.status(503).set("Retry-After", "60").type("html").send(
|
|
9720
|
+
"<!doctype html><html><body><p>We could not record your unsubscribe right now, so you are <strong>not</strong> unsubscribed yet. Please try again in a minute.</p></body></html>"
|
|
9721
|
+
);
|
|
9722
|
+
}
|
|
9723
|
+
function withTimeout(p, ms) {
|
|
9724
|
+
if (!Number.isFinite(ms) || ms <= 0) return p;
|
|
9725
|
+
return new Promise((resolve, reject) => {
|
|
9726
|
+
const timer = setTimeout(() => {
|
|
9727
|
+
reject(new Error(`timed out after ${ms}ms`));
|
|
9728
|
+
}, ms);
|
|
9729
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
9730
|
+
p.then(
|
|
9731
|
+
(v) => {
|
|
9732
|
+
clearTimeout(timer);
|
|
9733
|
+
resolve(v);
|
|
9734
|
+
},
|
|
9735
|
+
(err) => {
|
|
9736
|
+
clearTimeout(timer);
|
|
9737
|
+
reject(err);
|
|
9738
|
+
}
|
|
9739
|
+
);
|
|
9740
|
+
});
|
|
9741
|
+
}
|
|
7613
9742
|
function escapeHtml(s) {
|
|
7614
9743
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7615
9744
|
}
|
|
9745
|
+
var ALLOWED_REDIRECT_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
9746
|
+
function isSafeRedirectTarget(raw) {
|
|
9747
|
+
if (typeof raw !== "string") return false;
|
|
9748
|
+
const trimmed = raw.trim();
|
|
9749
|
+
if (trimmed === "") return false;
|
|
9750
|
+
let parsed;
|
|
9751
|
+
try {
|
|
9752
|
+
parsed = new URL(trimmed);
|
|
9753
|
+
} catch {
|
|
9754
|
+
return false;
|
|
9755
|
+
}
|
|
9756
|
+
return ALLOWED_REDIRECT_PROTOCOLS.has(parsed.protocol);
|
|
9757
|
+
}
|
|
7616
9758
|
function lowercaseHeaders(h) {
|
|
7617
9759
|
const out = {};
|
|
7618
9760
|
for (const k of Object.keys(h)) {
|
|
@@ -7781,40 +9923,60 @@ var DEDUPE_POLICIES = [
|
|
|
7781
9923
|
];
|
|
7782
9924
|
|
|
7783
9925
|
// src/server/index.ts
|
|
7784
|
-
var
|
|
9926
|
+
var VERSION2 = "0.16.0" ;
|
|
7785
9927
|
|
|
7786
9928
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
9929
|
+
exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;
|
|
7787
9930
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|
|
9931
|
+
exports.FlowOperationError = FlowOperationError;
|
|
9932
|
+
exports.MIN_AGENT_TOKEN_LENGTH = MIN_AGENT_TOKEN_LENGTH;
|
|
7788
9933
|
exports.Mailer = Mailer;
|
|
7789
9934
|
exports.NullProvider = NullProvider;
|
|
7790
9935
|
exports.PREDICATE_KINDS = PREDICATE_KINDS;
|
|
7791
9936
|
exports.SEGMENT_FILTER_KINDS = SEGMENT_FILTER_KINDS;
|
|
7792
|
-
exports.
|
|
9937
|
+
exports.TRACKING_SIG_LENGTH = TRACKING_SIG_LENGTH;
|
|
9938
|
+
exports.VERSION = VERSION2;
|
|
7793
9939
|
exports.applyTracking = applyTracking;
|
|
7794
9940
|
exports.applyWebhookEvent = applyWebhookEvent;
|
|
9941
|
+
exports.armFlow = armFlow;
|
|
7795
9942
|
exports.compileMailyTemplate = compileMailyTemplate;
|
|
7796
9943
|
exports.compileTemplate = compileTemplate;
|
|
7797
9944
|
exports.computeDeliveryTime = computeDeliveryTime;
|
|
9945
|
+
exports.createAdminApiRouter = createAdminApiRouter;
|
|
7798
9946
|
exports.createAdminRouter = createAdminRouter;
|
|
9947
|
+
exports.createAgentRouter = createAgentRouter;
|
|
7799
9948
|
exports.createPublicRouter = createPublicRouter;
|
|
7800
9949
|
exports.defaultFlowStep = defaultFlowStep;
|
|
7801
9950
|
exports.defaultPredicate = defaultPredicate;
|
|
7802
9951
|
exports.defaultSegmentFilter = defaultSegmentFilter;
|
|
7803
9952
|
exports.defineVars = defineVars;
|
|
7804
9953
|
exports.derivePlaintext = derivePlaintext;
|
|
9954
|
+
exports.disarmFlow = disarmFlow;
|
|
7805
9955
|
exports.dispatchSend = dispatchSend;
|
|
9956
|
+
exports.drainPendingUnsubscribes = drainPendingUnsubscribes;
|
|
7806
9957
|
exports.ensureIndexes = ensureIndexes;
|
|
9958
|
+
exports.gateFlow = gateFlow;
|
|
7807
9959
|
exports.getCollections = getCollections;
|
|
9960
|
+
exports.isCanaryGate = isCanaryGate;
|
|
7808
9961
|
exports.predicateKind = predicateKind;
|
|
7809
9962
|
exports.processNewlyFiredEventTriggers = processNewlyFiredEventTriggers;
|
|
7810
9963
|
exports.processOneRunStep = processOneRunStep;
|
|
9964
|
+
exports.referencedPaths = referencedPaths;
|
|
9965
|
+
exports.renderForContact = renderForContact;
|
|
7811
9966
|
exports.renderTemplate = renderTemplate;
|
|
7812
9967
|
exports.runTick = runTick;
|
|
9968
|
+
exports.sendgridInboundParser = sendgridInboundParser;
|
|
7813
9969
|
exports.sha256Hex = sha256Hex;
|
|
9970
|
+
exports.signTrackingToken = signTrackingToken;
|
|
7814
9971
|
exports.signUnsubscribeToken = signUnsubscribeToken;
|
|
9972
|
+
exports.simulateFlow = simulateFlow;
|
|
9973
|
+
exports.stampWatermarkIfNull = stampWatermarkIfNull;
|
|
7815
9974
|
exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
|
|
9975
|
+
exports.ungateFlow = ungateFlow;
|
|
7816
9976
|
exports.validateSenderDomain = validateSenderDomain;
|
|
7817
9977
|
exports.varsJsonSchema = varsJsonSchema;
|
|
9978
|
+
exports.verifyTemplate = verifyTemplate;
|
|
9979
|
+
exports.verifyTrackingToken = verifyTrackingToken;
|
|
7818
9980
|
exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
|
|
7819
9981
|
//# sourceMappingURL=index.cjs.map
|
|
7820
9982
|
//# sourceMappingURL=index.cjs.map
|