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.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ObjectId } from 'mongodb';
|
|
3
|
-
import crypto2 from 'crypto';
|
|
3
|
+
import crypto2, { randomBytes, createHash, timingSafeEqual } from 'crypto';
|
|
4
4
|
import sgMail from '@sendgrid/mail';
|
|
5
5
|
import IORedis from 'ioredis';
|
|
6
6
|
import Handlebars from 'handlebars';
|
|
@@ -9,22 +9,22 @@ import mjml2html from 'mjml';
|
|
|
9
9
|
import dns from 'dns/promises';
|
|
10
10
|
import net from 'net';
|
|
11
11
|
import psl from 'psl';
|
|
12
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
12
15
|
import express, { Router } from 'express';
|
|
13
16
|
import multer from 'multer';
|
|
14
|
-
import path from 'path';
|
|
15
17
|
import { fileURLToPath } from 'url';
|
|
16
|
-
import fs from 'fs';
|
|
17
18
|
|
|
18
19
|
var __defProp = Object.defineProperty;
|
|
19
20
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
21
|
+
var __esm = (fn, res, err) => function __init() {
|
|
22
|
+
if (err) throw err[0];
|
|
23
|
+
try {
|
|
24
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw err = [e], e;
|
|
27
|
+
}
|
|
28
28
|
};
|
|
29
29
|
var __export = (target, all) => {
|
|
30
30
|
for (var name in all)
|
|
@@ -240,6 +240,31 @@ var init_mongo = __esm({
|
|
|
240
240
|
}
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
// src/server/providers/webhook-tolerance.ts
|
|
244
|
+
function resolveWebhookToleranceSeconds(option) {
|
|
245
|
+
if (option === false || option === 0) return 0;
|
|
246
|
+
if (option === void 0 || option === null) return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
247
|
+
if (typeof option !== "number" || !Number.isFinite(option) || option < 0) {
|
|
248
|
+
return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
249
|
+
}
|
|
250
|
+
return option;
|
|
251
|
+
}
|
|
252
|
+
function isWebhookTimestampFresh(rawTimestamp, toleranceSeconds, now = /* @__PURE__ */ new Date()) {
|
|
253
|
+
if (toleranceSeconds === 0) return true;
|
|
254
|
+
if (!rawTimestamp) return false;
|
|
255
|
+
if (!/^\d{1,15}$/.test(rawTimestamp)) return false;
|
|
256
|
+
const timestampMs = Number(rawTimestamp) * 1e3;
|
|
257
|
+
if (!Number.isFinite(timestampMs)) return false;
|
|
258
|
+
const deltaMs = Math.abs(now.getTime() - timestampMs);
|
|
259
|
+
return deltaMs <= toleranceSeconds * 1e3;
|
|
260
|
+
}
|
|
261
|
+
var DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
262
|
+
var init_webhook_tolerance = __esm({
|
|
263
|
+
"src/server/providers/webhook-tolerance.ts"() {
|
|
264
|
+
DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
243
268
|
// src/server/providers/sendgrid.ts
|
|
244
269
|
var sendgrid_exports = {};
|
|
245
270
|
__export(sendgrid_exports, {
|
|
@@ -286,6 +311,7 @@ function mapEventType(sgEvent) {
|
|
|
286
311
|
var SG_SIG_HEADER, SG_TS_HEADER, SendGridProvider;
|
|
287
312
|
var init_sendgrid = __esm({
|
|
288
313
|
"src/server/providers/sendgrid.ts"() {
|
|
314
|
+
init_webhook_tolerance();
|
|
289
315
|
SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
|
|
290
316
|
SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
|
|
291
317
|
SendGridProvider = class {
|
|
@@ -293,10 +319,13 @@ var init_sendgrid = __esm({
|
|
|
293
319
|
this.opts = opts;
|
|
294
320
|
sgMail.setApiKey(opts.apiKey);
|
|
295
321
|
this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
|
|
322
|
+
this.webhookToleranceSeconds = resolveWebhookToleranceSeconds(opts.webhookToleranceSeconds);
|
|
296
323
|
}
|
|
297
324
|
opts;
|
|
298
325
|
name = "sendgrid";
|
|
299
326
|
sendRatePerSecond;
|
|
327
|
+
/** Resolved replay window in seconds; `0` means the check is disabled. */
|
|
328
|
+
webhookToleranceSeconds;
|
|
300
329
|
async send(args) {
|
|
301
330
|
const msg = {
|
|
302
331
|
to: args.to,
|
|
@@ -336,10 +365,11 @@ var init_sendgrid = __esm({
|
|
|
336
365
|
try {
|
|
337
366
|
const verifier = crypto2.createVerify("sha256");
|
|
338
367
|
verifier.update(payload);
|
|
339
|
-
|
|
368
|
+
if (!verifier.verify(this.opts.webhookVerificationKey, sig, "base64")) return false;
|
|
340
369
|
} catch {
|
|
341
370
|
return false;
|
|
342
371
|
}
|
|
372
|
+
return isWebhookTimestampFresh(ts, this.webhookToleranceSeconds);
|
|
343
373
|
}
|
|
344
374
|
parseWebhookEvents(payload) {
|
|
345
375
|
if (!Array.isArray(payload)) return [];
|
|
@@ -469,6 +499,10 @@ var flowStepSchema = z.lazy(
|
|
|
469
499
|
}),
|
|
470
500
|
z.object({
|
|
471
501
|
type: z.literal("webhook"),
|
|
502
|
+
// `url` is validated for shape only and is deliberately never templated —
|
|
503
|
+
// see the comment on handleWebhookStep in src/server/runner/step.ts, and
|
|
504
|
+
// INVARIANT 16. Rendering contact/event data into this field would turn an
|
|
505
|
+
// admin-authored flow into a server-side request forgery primitive.
|
|
472
506
|
url: z.string().url(),
|
|
473
507
|
method: z.enum(["POST", "PUT"]).optional(),
|
|
474
508
|
payload: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -562,6 +596,7 @@ var DEFAULTS = {
|
|
|
562
596
|
requireDoubleOptIn: false,
|
|
563
597
|
unsubscribeTokenLifetimeDays: 90,
|
|
564
598
|
transactionalRespectUnsubscribe: false,
|
|
599
|
+
unsubscribeWriteTimeoutMs: 5e3,
|
|
565
600
|
doiTemplateSlug: "doi-confirmation",
|
|
566
601
|
doiTokenLifetimeDays: 7,
|
|
567
602
|
broadcastConfirmationThreshold: 1e3,
|
|
@@ -578,7 +613,9 @@ var DEFAULTS = {
|
|
|
578
613
|
trackOpens: true,
|
|
579
614
|
trackClicks: true,
|
|
580
615
|
storeTrackingIp: false,
|
|
581
|
-
storeRenderedBody: false
|
|
616
|
+
storeRenderedBody: false,
|
|
617
|
+
requireSignedTrackingUrls: false,
|
|
618
|
+
trackingUrlLifetimeDays: 0
|
|
582
619
|
};
|
|
583
620
|
var CIRCUIT_BREAKER_DEFAULTS = {
|
|
584
621
|
hardBounceRatePctTrip: 2,
|
|
@@ -595,6 +632,7 @@ function resolveConfig(c) {
|
|
|
595
632
|
requireDoubleOptIn: c.requireDoubleOptIn ?? DEFAULTS.requireDoubleOptIn,
|
|
596
633
|
unsubscribeTokenLifetimeDays: c.unsubscribeTokenLifetimeDays ?? DEFAULTS.unsubscribeTokenLifetimeDays,
|
|
597
634
|
transactionalRespectUnsubscribe: c.transactionalRespectUnsubscribe ?? DEFAULTS.transactionalRespectUnsubscribe,
|
|
635
|
+
unsubscribeWriteTimeoutMs: c.unsubscribeWriteTimeoutMs ?? DEFAULTS.unsubscribeWriteTimeoutMs,
|
|
598
636
|
doiTemplateSlug: c.doiTemplateSlug ?? DEFAULTS.doiTemplateSlug,
|
|
599
637
|
doiTokenLifetimeDays: c.doiTokenLifetimeDays ?? DEFAULTS.doiTokenLifetimeDays,
|
|
600
638
|
broadcastConfirmationThreshold: c.broadcastConfirmationThreshold ?? DEFAULTS.broadcastConfirmationThreshold,
|
|
@@ -612,6 +650,8 @@ function resolveConfig(c) {
|
|
|
612
650
|
trackClicks: c.trackClicks ?? DEFAULTS.trackClicks,
|
|
613
651
|
storeTrackingIp: c.storeTrackingIp ?? DEFAULTS.storeTrackingIp,
|
|
614
652
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
653
|
+
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
654
|
+
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
615
655
|
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
616
656
|
};
|
|
617
657
|
}
|
|
@@ -814,6 +854,22 @@ function isoDay(d) {
|
|
|
814
854
|
function randomId() {
|
|
815
855
|
return crypto2.randomBytes(8).toString("hex");
|
|
816
856
|
}
|
|
857
|
+
|
|
858
|
+
// src/server/provider-lookup.ts
|
|
859
|
+
var PROVIDER_METHODS = ["send", "verifyWebhook", "parseWebhookEvents"];
|
|
860
|
+
function isMailProvider(candidate) {
|
|
861
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
862
|
+
const p = candidate;
|
|
863
|
+
return PROVIDER_METHODS.every((m) => typeof p[m] === "function");
|
|
864
|
+
}
|
|
865
|
+
function resolveProvider(providers, name) {
|
|
866
|
+
if (typeof name !== "string" || !Object.hasOwn(providers, name)) return null;
|
|
867
|
+
const candidate = providers[name];
|
|
868
|
+
return isMailProvider(candidate) ? candidate : null;
|
|
869
|
+
}
|
|
870
|
+
function registeredProviderNames(providers) {
|
|
871
|
+
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
872
|
+
}
|
|
817
873
|
function signUnsubscribeToken(payload, secret) {
|
|
818
874
|
const body = JSON.stringify({
|
|
819
875
|
e: payload.email.toLowerCase(),
|
|
@@ -852,6 +908,28 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
|
|
|
852
908
|
expiresAt: new Date(body.x)
|
|
853
909
|
};
|
|
854
910
|
}
|
|
911
|
+
var TRACKING_SCOPES = ["open", "click"];
|
|
912
|
+
var TRACKING_SIG_LENGTH = 12;
|
|
913
|
+
function trackingMessage(scope, params) {
|
|
914
|
+
const tail = params.linkId === void 0 ? "" : `:${params.linkId}`;
|
|
915
|
+
return `mailery.track.v1:${scope}:${params.sendId}${tail}`;
|
|
916
|
+
}
|
|
917
|
+
function signTrackingToken(scope, params, secret) {
|
|
918
|
+
if (!TRACKING_SCOPES.includes(scope)) {
|
|
919
|
+
throw new Error(`signTrackingToken: unknown scope ${String(scope)}`);
|
|
920
|
+
}
|
|
921
|
+
const mac = crypto2.createHmac("sha256", secret).update(trackingMessage(scope, params)).digest();
|
|
922
|
+
return b64url(mac).slice(0, TRACKING_SIG_LENGTH);
|
|
923
|
+
}
|
|
924
|
+
function verifyTrackingToken(token, scope, params, secret) {
|
|
925
|
+
if (typeof token !== "string") return false;
|
|
926
|
+
if (!TRACKING_SCOPES.includes(scope)) return false;
|
|
927
|
+
if (token.length !== TRACKING_SIG_LENGTH) return false;
|
|
928
|
+
const expected = Buffer.from(signTrackingToken(scope, params, secret), "utf8");
|
|
929
|
+
const actual = Buffer.from(token, "utf8");
|
|
930
|
+
if (expected.length !== actual.length) return false;
|
|
931
|
+
return crypto2.timingSafeEqual(expected, actual);
|
|
932
|
+
}
|
|
855
933
|
function b64url(buf) {
|
|
856
934
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
857
935
|
}
|
|
@@ -895,6 +973,39 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
895
973
|
if (body.x < now.getTime()) return null;
|
|
896
974
|
return { externalId: body.i, expiresAt: new Date(body.x) };
|
|
897
975
|
}
|
|
976
|
+
|
|
977
|
+
// src/server/unsubscribe.ts
|
|
978
|
+
async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
|
|
979
|
+
const normalized = input.email;
|
|
980
|
+
await collections.suppressions.updateOne(
|
|
981
|
+
{ email: normalized, scope: input.scope },
|
|
982
|
+
{
|
|
983
|
+
$setOnInsert: {
|
|
984
|
+
email: normalized,
|
|
985
|
+
emailHash: sha256Hex(normalized),
|
|
986
|
+
scope: input.scope,
|
|
987
|
+
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
988
|
+
reason: "unsubscribed",
|
|
989
|
+
source: input.source,
|
|
990
|
+
notes: input.notes ?? null,
|
|
991
|
+
addedAt: now,
|
|
992
|
+
expiresAt: null
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
{ upsert: true }
|
|
996
|
+
);
|
|
997
|
+
await collections.subscriptions.updateOne(
|
|
998
|
+
{ emailAtSubscribe: normalized },
|
|
999
|
+
{
|
|
1000
|
+
$set: {
|
|
1001
|
+
status: "unsubscribed",
|
|
1002
|
+
unsubscribedAt: now,
|
|
1003
|
+
unsubscribeReason: input.reason,
|
|
1004
|
+
updatedAt: now
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
898
1009
|
var QUEUE_NAMES = {
|
|
899
1010
|
tick: "mailer-tick",
|
|
900
1011
|
advance: "mailer-advance",
|
|
@@ -1727,7 +1838,7 @@ async function suppressOnce(ctx, email, reason, scope) {
|
|
|
1727
1838
|
}
|
|
1728
1839
|
|
|
1729
1840
|
// src/server/runner/predicate.ts
|
|
1730
|
-
var
|
|
1841
|
+
var DEFAULT_BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1731
1842
|
async function evaluatePredicate(predicate, ctx) {
|
|
1732
1843
|
const p = predicate;
|
|
1733
1844
|
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
@@ -1795,17 +1906,35 @@ async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
|
1795
1906
|
return await ctx.collections.sends.countDocuments(filter);
|
|
1796
1907
|
}
|
|
1797
1908
|
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1909
|
+
const botRe = ctx.botFilter?.userAgentPattern ?? DEFAULT_BOT_UA_RE;
|
|
1910
|
+
const minOpenDelayMs = ctx.botFilter?.minOpenDelayMs ?? 0;
|
|
1798
1911
|
let n = 0;
|
|
1799
1912
|
for (const s of docs) {
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
}
|
|
1804
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1805
|
-
if (hasHumanClick) n++;
|
|
1913
|
+
const doc = s;
|
|
1914
|
+
const human = kind === "opened" ? hasHumanOpen(doc, botRe, minOpenDelayMs) : (doc.clickedLinks ?? []).some((c) => !isBotUserAgent(c.userAgent, botRe));
|
|
1915
|
+
if (human) n++;
|
|
1806
1916
|
}
|
|
1807
1917
|
return n;
|
|
1808
1918
|
}
|
|
1919
|
+
function isBotUserAgent(ua, botRe) {
|
|
1920
|
+
if (typeof ua !== "string" || ua.trim() === "") return false;
|
|
1921
|
+
return botRe.test(ua);
|
|
1922
|
+
}
|
|
1923
|
+
function hasHumanOpen(send, botRe, minOpenDelayMs) {
|
|
1924
|
+
const opens = send.opens && send.opens.length > 0 ? send.opens : send.openedAt ? [{ openedAt: send.openedAt, userAgent: null }] : [];
|
|
1925
|
+
return opens.some((o) => {
|
|
1926
|
+
if (isBotUserAgent(o.userAgent, botRe)) return false;
|
|
1927
|
+
if (isPrefetchOpen(o.openedAt, send.queuedAt, minOpenDelayMs)) return false;
|
|
1928
|
+
return true;
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
function isPrefetchOpen(openedAt, queuedAt, minOpenDelayMs) {
|
|
1932
|
+
if (!minOpenDelayMs || minOpenDelayMs <= 0) return false;
|
|
1933
|
+
if (!(openedAt instanceof Date) || !(queuedAt instanceof Date)) return false;
|
|
1934
|
+
const delta = openedAt.getTime() - queuedAt.getTime();
|
|
1935
|
+
if (!Number.isFinite(delta) || delta < 0) return false;
|
|
1936
|
+
return delta < minOpenDelayMs;
|
|
1937
|
+
}
|
|
1809
1938
|
function effectiveLowerBound(ctx, opts) {
|
|
1810
1939
|
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1811
1940
|
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
@@ -1971,12 +2100,14 @@ function applyTracking(html, opts) {
|
|
|
1971
2100
|
seen.set(url, linkId);
|
|
1972
2101
|
links.push({ linkId, url });
|
|
1973
2102
|
}
|
|
1974
|
-
const
|
|
2103
|
+
const sig = opts.signingSecret ? `/${signTrackingToken("click", { sendId: opts.sendId, linkId }, opts.signingSecret)}` : "";
|
|
2104
|
+
const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}${sig}`;
|
|
1975
2105
|
return `<a${pre}href="${newHref}"${post}>`;
|
|
1976
2106
|
});
|
|
1977
2107
|
}
|
|
1978
2108
|
if (opts.trackOpens) {
|
|
1979
|
-
const
|
|
2109
|
+
const sig = opts.signingSecret ? `.${signTrackingToken("open", { sendId: opts.sendId }, opts.signingSecret)}` : "";
|
|
2110
|
+
const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}${sig}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
|
|
1980
2111
|
if (/<\/body>/i.test(out)) {
|
|
1981
2112
|
out = out.replace(/<\/body>/i, `${pixel}</body>`);
|
|
1982
2113
|
} else {
|
|
@@ -1987,14 +2118,22 @@ function applyTracking(html, opts) {
|
|
|
1987
2118
|
}
|
|
1988
2119
|
function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
1989
2120
|
if (!url) return true;
|
|
1990
|
-
if (url.startsWith("mailto:")) return true;
|
|
1991
|
-
if (url.startsWith("tel:")) return true;
|
|
1992
2121
|
if (url.startsWith("#")) return true;
|
|
1993
2122
|
if (url.startsWith("{{") || url.startsWith("{")) return true;
|
|
1994
2123
|
if (preserve.has(url.trim())) return true;
|
|
1995
2124
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
2125
|
+
if (!isHttpUrl(url)) return true;
|
|
1996
2126
|
return false;
|
|
1997
2127
|
}
|
|
2128
|
+
function isHttpUrl(url) {
|
|
2129
|
+
let parsed;
|
|
2130
|
+
try {
|
|
2131
|
+
parsed = new URL(url.trim());
|
|
2132
|
+
} catch {
|
|
2133
|
+
return false;
|
|
2134
|
+
}
|
|
2135
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
2136
|
+
}
|
|
1998
2137
|
var NAMED_ENTITIES = {
|
|
1999
2138
|
amp: "&",
|
|
2000
2139
|
lt: "<",
|
|
@@ -2215,7 +2354,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2215
2354
|
publicUrl: ctx.config.publicUrl,
|
|
2216
2355
|
trackOpens: template.trackOpens ?? ctx.config.trackOpens,
|
|
2217
2356
|
trackClicks: template.trackClicks ?? ctx.config.trackClicks,
|
|
2218
|
-
preserveUrls: [renderCtx.unsubscribeUrl]
|
|
2357
|
+
preserveUrls: [renderCtx.unsubscribeUrl],
|
|
2358
|
+
signingSecret: ctx.config.unsubscribeSecret
|
|
2219
2359
|
});
|
|
2220
2360
|
await ctx.collections.sends.updateOne(
|
|
2221
2361
|
{ _id: send._id },
|
|
@@ -2233,9 +2373,14 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2233
2373
|
}
|
|
2234
2374
|
}
|
|
2235
2375
|
);
|
|
2236
|
-
const provider = ctx.providers
|
|
2376
|
+
const provider = resolveProvider(ctx.providers, send.provider);
|
|
2237
2377
|
if (!provider) {
|
|
2238
|
-
|
|
2378
|
+
const known = registeredProviderNames(ctx.providers);
|
|
2379
|
+
await markFailed(
|
|
2380
|
+
send._id,
|
|
2381
|
+
`provider_unknown: no provider is registered as "${send.provider}". Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
|
|
2382
|
+
ctx
|
|
2383
|
+
);
|
|
2239
2384
|
return;
|
|
2240
2385
|
}
|
|
2241
2386
|
const headers = send.kind === "marketing" ? {
|
|
@@ -2434,7 +2579,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2434
2579
|
}
|
|
2435
2580
|
async function handleCondition(run, step, contact, ctx) {
|
|
2436
2581
|
if (!contact) return;
|
|
2437
|
-
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
|
|
2582
|
+
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections, botFilter: ctx.config.botFilter });
|
|
2438
2583
|
if (result) {
|
|
2439
2584
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
2440
2585
|
return;
|
|
@@ -2447,7 +2592,7 @@ async function handleCondition(run, step, contact, ctx) {
|
|
|
2447
2592
|
}
|
|
2448
2593
|
async function handleBranch(run, step, contact, ctx) {
|
|
2449
2594
|
if (!contact) return;
|
|
2450
|
-
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
|
|
2595
|
+
const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections, botFilter: ctx.config.botFilter });
|
|
2451
2596
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
2452
2597
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2453
2598
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -3591,8 +3736,6 @@ function nthSundayOfMonth(year, month0, n) {
|
|
|
3591
3736
|
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
3592
3737
|
return firstSunday + (n - 1) * 7;
|
|
3593
3738
|
}
|
|
3594
|
-
|
|
3595
|
-
// src/server/runner/dmarc.ts
|
|
3596
3739
|
var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
|
|
3597
3740
|
function assertSafeZipEntryName(rawName) {
|
|
3598
3741
|
if (typeof rawName !== "string" || rawName.length === 0) {
|
|
@@ -3666,7 +3809,6 @@ async function extractDmarcXmls(buffer, filename) {
|
|
|
3666
3809
|
return [buffer.toString("utf8")];
|
|
3667
3810
|
}
|
|
3668
3811
|
function parseDmarcReport(xml) {
|
|
3669
|
-
const { XMLParser } = __require("fast-xml-parser");
|
|
3670
3812
|
const parser = new XMLParser({
|
|
3671
3813
|
ignoreAttributes: false,
|
|
3672
3814
|
parseAttributeValue: false,
|
|
@@ -3757,7 +3899,7 @@ function toArray(v) {
|
|
|
3757
3899
|
if (v === void 0 || v === null) return [];
|
|
3758
3900
|
return Array.isArray(v) ? v : [v];
|
|
3759
3901
|
}
|
|
3760
|
-
async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
3902
|
+
async function ingestDmarcAttachment(ctx, buffer, filename, opts = {}) {
|
|
3761
3903
|
const xmls = await extractDmarcXmls(buffer, filename);
|
|
3762
3904
|
if (xmls.length === 0) throw new Error("attachment contained no XML payload");
|
|
3763
3905
|
const results = [];
|
|
@@ -3765,6 +3907,9 @@ async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
|
3765
3907
|
for (const xml of xmls) {
|
|
3766
3908
|
try {
|
|
3767
3909
|
const parsed = parseDmarcReport(xml);
|
|
3910
|
+
if (opts.allowDomain && !opts.allowDomain(parsed.report.domain)) {
|
|
3911
|
+
throw new Error(`DMARC report is for domain "${parsed.report.domain}", which this deployment does not send from`);
|
|
3912
|
+
}
|
|
3768
3913
|
results.push(await ingestParsedDmarcReport(ctx, parsed));
|
|
3769
3914
|
} catch (err) {
|
|
3770
3915
|
if (!firstError) firstError = err;
|
|
@@ -3875,6 +4020,260 @@ function suggestPolicyProgression(input) {
|
|
|
3875
4020
|
}
|
|
3876
4021
|
return null;
|
|
3877
4022
|
}
|
|
4023
|
+
var DRAINING_INFIX = ".draining.";
|
|
4024
|
+
var ADOPT_AFTER_MS = 5 * 60 * 1e3;
|
|
4025
|
+
var JOURNAL_WARN_BYTES = 8 * 1024 * 1024;
|
|
4026
|
+
var O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
4027
|
+
var APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW;
|
|
4028
|
+
function appendPendingUnsub(journalPath, entry) {
|
|
4029
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true, mode: 448 });
|
|
4030
|
+
const fd = fs.openSync(journalPath, APPEND_FLAGS, 384);
|
|
4031
|
+
try {
|
|
4032
|
+
assertPrivateRegularFile(fd, journalPath);
|
|
4033
|
+
fs.writeSync(fd, serializeEntry(entry));
|
|
4034
|
+
} finally {
|
|
4035
|
+
fs.closeSync(fd);
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
function assertPrivateRegularFile(fd, journalPath) {
|
|
4039
|
+
const st = fs.fstatSync(fd);
|
|
4040
|
+
if (!st.isFile()) {
|
|
4041
|
+
throw new Error(`pending-unsubscribe journal is not a regular file: ${journalPath}`);
|
|
4042
|
+
}
|
|
4043
|
+
if (st.nlink > 1) {
|
|
4044
|
+
throw new Error(`pending-unsubscribe journal has ${st.nlink} hard links: ${journalPath}`);
|
|
4045
|
+
}
|
|
4046
|
+
if ((st.mode & 63) !== 0) fs.fchmodSync(fd, 384);
|
|
4047
|
+
}
|
|
4048
|
+
function serializeEntry(entry) {
|
|
4049
|
+
return JSON.stringify(entry) + "\n";
|
|
4050
|
+
}
|
|
4051
|
+
function claimToken() {
|
|
4052
|
+
return `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
4053
|
+
}
|
|
4054
|
+
function claimJournal(journalPath) {
|
|
4055
|
+
const claimed = `${journalPath}${DRAINING_INFIX}${claimToken()}`;
|
|
4056
|
+
try {
|
|
4057
|
+
fs.renameSync(journalPath, claimed);
|
|
4058
|
+
} catch (err) {
|
|
4059
|
+
if (isMissing(err)) return null;
|
|
4060
|
+
throw err;
|
|
4061
|
+
}
|
|
4062
|
+
markClaimActive(claimed);
|
|
4063
|
+
return claimed;
|
|
4064
|
+
}
|
|
4065
|
+
function markClaimActive(claimPath) {
|
|
4066
|
+
const now = /* @__PURE__ */ new Date();
|
|
4067
|
+
try {
|
|
4068
|
+
fs.utimesSync(claimPath, now, now);
|
|
4069
|
+
} catch (err) {
|
|
4070
|
+
if (!isMissing(err)) throw err;
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
function adoptAbandonedClaims(journalPath, opts = {}) {
|
|
4074
|
+
const olderThanMs = opts.olderThanMs ?? ADOPT_AFTER_MS;
|
|
4075
|
+
const now = opts.now ?? Date.now();
|
|
4076
|
+
const dir = path.dirname(journalPath);
|
|
4077
|
+
const prefix = path.basename(journalPath) + DRAINING_INFIX;
|
|
4078
|
+
let names;
|
|
4079
|
+
try {
|
|
4080
|
+
names = fs.readdirSync(dir);
|
|
4081
|
+
} catch (err) {
|
|
4082
|
+
if (isMissing(err)) return [];
|
|
4083
|
+
throw err;
|
|
4084
|
+
}
|
|
4085
|
+
const adopted = [];
|
|
4086
|
+
for (const name of names) {
|
|
4087
|
+
if (!name.startsWith(prefix)) continue;
|
|
4088
|
+
const full = path.join(dir, name);
|
|
4089
|
+
let st;
|
|
4090
|
+
try {
|
|
4091
|
+
st = fs.statSync(full);
|
|
4092
|
+
} catch (err) {
|
|
4093
|
+
if (isMissing(err)) continue;
|
|
4094
|
+
throw err;
|
|
4095
|
+
}
|
|
4096
|
+
if (!st.isFile()) continue;
|
|
4097
|
+
if (now - st.mtimeMs < olderThanMs) continue;
|
|
4098
|
+
const mine = `${journalPath}${DRAINING_INFIX}${claimToken()}`;
|
|
4099
|
+
try {
|
|
4100
|
+
fs.renameSync(full, mine);
|
|
4101
|
+
markClaimActive(mine);
|
|
4102
|
+
adopted.push(mine);
|
|
4103
|
+
} catch (err) {
|
|
4104
|
+
if (!isMissing(err)) throw err;
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
return adopted;
|
|
4108
|
+
}
|
|
4109
|
+
function readClaim(claimPath) {
|
|
4110
|
+
let raw;
|
|
4111
|
+
try {
|
|
4112
|
+
raw = fs.readFileSync(claimPath, "utf8");
|
|
4113
|
+
} catch (err) {
|
|
4114
|
+
if (isMissing(err)) return { entries: [], malformed: [], bytes: 0 };
|
|
4115
|
+
throw err;
|
|
4116
|
+
}
|
|
4117
|
+
const entries = [];
|
|
4118
|
+
const malformed = [];
|
|
4119
|
+
const lines = raw.split("\n");
|
|
4120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4121
|
+
const line = lines[i];
|
|
4122
|
+
if (line.trim() === "") continue;
|
|
4123
|
+
const parsed = parseEntry(line);
|
|
4124
|
+
if (parsed) entries.push(parsed);
|
|
4125
|
+
else malformed.push({ lineNumber: i + 1, preview: line.slice(0, 120) });
|
|
4126
|
+
}
|
|
4127
|
+
return { entries, malformed, bytes: Buffer.byteLength(raw) };
|
|
4128
|
+
}
|
|
4129
|
+
function parseEntry(line) {
|
|
4130
|
+
let obj;
|
|
4131
|
+
try {
|
|
4132
|
+
obj = JSON.parse(line);
|
|
4133
|
+
} catch {
|
|
4134
|
+
return null;
|
|
4135
|
+
}
|
|
4136
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
|
|
4137
|
+
const o = obj;
|
|
4138
|
+
const check = unsubscribeInputSchema.safeParse({
|
|
4139
|
+
email: o.email,
|
|
4140
|
+
scope: o.scope,
|
|
4141
|
+
reason: "user_request",
|
|
4142
|
+
source: "one-click"
|
|
4143
|
+
});
|
|
4144
|
+
if (!check.success) return null;
|
|
4145
|
+
const at = typeof o.at === "number" && Number.isFinite(o.at) ? o.at : Date.now();
|
|
4146
|
+
const attempts = typeof o.attempts === "number" && Number.isFinite(o.attempts) && o.attempts > 0 ? Math.floor(o.attempts) : void 0;
|
|
4147
|
+
return {
|
|
4148
|
+
email: check.data.email,
|
|
4149
|
+
scope: check.data.scope,
|
|
4150
|
+
at,
|
|
4151
|
+
...attempts === void 0 ? {} : { attempts }
|
|
4152
|
+
};
|
|
4153
|
+
}
|
|
4154
|
+
function entryToInput(entry) {
|
|
4155
|
+
return unsubscribeInputSchema.parse({
|
|
4156
|
+
email: entry.email,
|
|
4157
|
+
scope: entry.scope,
|
|
4158
|
+
reason: "user_request",
|
|
4159
|
+
// Distinguishable in `mailer_suppressions.source` from a live one-click,
|
|
4160
|
+
// but only on rows the drain actually created (the upsert is
|
|
4161
|
+
// `$setOnInsert`, so a row the live path already wrote keeps its own).
|
|
4162
|
+
source: "one-click-drain"
|
|
4163
|
+
});
|
|
4164
|
+
}
|
|
4165
|
+
function releaseClaim(journalPath, claimPath, remaining) {
|
|
4166
|
+
if (remaining.length > 0) {
|
|
4167
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true, mode: 448 });
|
|
4168
|
+
const fd = fs.openSync(journalPath, APPEND_FLAGS, 384);
|
|
4169
|
+
try {
|
|
4170
|
+
assertPrivateRegularFile(fd, journalPath);
|
|
4171
|
+
fs.writeSync(
|
|
4172
|
+
fd,
|
|
4173
|
+
remaining.map((e) => serializeEntry({ ...e, attempts: (e.attempts ?? 0) + 1 })).join("")
|
|
4174
|
+
);
|
|
4175
|
+
} finally {
|
|
4176
|
+
fs.closeSync(fd);
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
try {
|
|
4180
|
+
fs.unlinkSync(claimPath);
|
|
4181
|
+
} catch (err) {
|
|
4182
|
+
if (!isMissing(err)) throw err;
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
function journalExists(journalPath) {
|
|
4186
|
+
try {
|
|
4187
|
+
return fs.statSync(journalPath).isFile();
|
|
4188
|
+
} catch (err) {
|
|
4189
|
+
if (isMissing(err)) return false;
|
|
4190
|
+
throw err;
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
function isMissing(err) {
|
|
4194
|
+
const code = err?.code;
|
|
4195
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
// src/server/runner/pending-unsubs.ts
|
|
4199
|
+
var MAX_ENTRIES_PER_PASS = 1e3;
|
|
4200
|
+
var NOISY_AFTER_ATTEMPTS = 10;
|
|
4201
|
+
var consoleLog = {
|
|
4202
|
+
warn: (fields, msg) => console.warn(msg ?? "mailery", fields),
|
|
4203
|
+
error: (fields, msg) => console.error(msg ?? "mailery", fields)
|
|
4204
|
+
};
|
|
4205
|
+
async function drainPendingUnsubscribes(ctx, opts = {}) {
|
|
4206
|
+
const journalPath = opts.path ?? ctx.config.pendingUnsubsPath;
|
|
4207
|
+
const result = {
|
|
4208
|
+
enabled: Boolean(journalPath),
|
|
4209
|
+
applied: 0,
|
|
4210
|
+
deferred: 0,
|
|
4211
|
+
malformed: 0,
|
|
4212
|
+
batches: 0
|
|
4213
|
+
};
|
|
4214
|
+
if (!journalPath) return result;
|
|
4215
|
+
const log = opts.log ?? consoleLog;
|
|
4216
|
+
const budget = opts.maxEntries ?? MAX_ENTRIES_PER_PASS;
|
|
4217
|
+
const claims = [];
|
|
4218
|
+
try {
|
|
4219
|
+
claims.push(...adoptAbandonedClaims(journalPath));
|
|
4220
|
+
} catch (err) {
|
|
4221
|
+
log.error?.({ err, path: journalPath }, "mailery: pending-unsub claim adoption failed");
|
|
4222
|
+
}
|
|
4223
|
+
if (journalExists(journalPath)) {
|
|
4224
|
+
const claimed = claimJournal(journalPath);
|
|
4225
|
+
if (claimed) claims.push(claimed);
|
|
4226
|
+
}
|
|
4227
|
+
if (claims.length === 0) return result;
|
|
4228
|
+
let remainingBudget = budget;
|
|
4229
|
+
for (const claimPath of claims) {
|
|
4230
|
+
result.batches++;
|
|
4231
|
+
const batch = readClaim(claimPath);
|
|
4232
|
+
if (batch.bytes > JOURNAL_WARN_BYTES) {
|
|
4233
|
+
log.warn?.(
|
|
4234
|
+
{ path: claimPath, bytes: batch.bytes, entries: batch.entries.length },
|
|
4235
|
+
"mailery: pending-unsubscribe journal is large \u2014 Mongo has been unreachable for a while"
|
|
4236
|
+
);
|
|
4237
|
+
}
|
|
4238
|
+
for (const bad of batch.malformed) {
|
|
4239
|
+
result.malformed++;
|
|
4240
|
+
log.warn?.(
|
|
4241
|
+
{ path: claimPath, lineNumber: bad.lineNumber, preview: bad.preview },
|
|
4242
|
+
"mailery: pending-unsubscribe journal line unusable \u2014 skipped"
|
|
4243
|
+
);
|
|
4244
|
+
}
|
|
4245
|
+
const deferred = [];
|
|
4246
|
+
let stop = false;
|
|
4247
|
+
for (let i = 0; i < batch.entries.length; i++) {
|
|
4248
|
+
const entry = batch.entries[i];
|
|
4249
|
+
if (stop || remainingBudget <= 0) {
|
|
4250
|
+
deferred.push(entry);
|
|
4251
|
+
continue;
|
|
4252
|
+
}
|
|
4253
|
+
try {
|
|
4254
|
+
await applyUnsubscribe(ctx.collections, entryToInput(entry));
|
|
4255
|
+
result.applied++;
|
|
4256
|
+
remainingBudget--;
|
|
4257
|
+
} catch (err) {
|
|
4258
|
+
stop = true;
|
|
4259
|
+
deferred.push(entry);
|
|
4260
|
+
const attempts = (entry.attempts ?? 0) + 1;
|
|
4261
|
+
const fields = { err, path: claimPath, attempts, pending: batch.entries.length - i };
|
|
4262
|
+
if (attempts >= NOISY_AFTER_ATTEMPTS) {
|
|
4263
|
+
log.error?.(
|
|
4264
|
+
fields,
|
|
4265
|
+
"mailery: pending unsubscribe still not applied after repeated attempts \u2014 investigate"
|
|
4266
|
+
);
|
|
4267
|
+
} else {
|
|
4268
|
+
log.warn?.(fields, "mailery: pending-unsubscribe replay failed \u2014 deferred to next tick");
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
result.deferred += deferred.length;
|
|
4273
|
+
releaseClaim(journalPath, claimPath, deferred);
|
|
4274
|
+
}
|
|
4275
|
+
return result;
|
|
4276
|
+
}
|
|
3878
4277
|
|
|
3879
4278
|
// src/server/runner/tick.ts
|
|
3880
4279
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
@@ -3916,6 +4315,9 @@ async function runTick(ctx) {
|
|
|
3916
4315
|
await drainOutbox(ctx).catch((err) => {
|
|
3917
4316
|
console.error("mailery: outbox drain failed", err);
|
|
3918
4317
|
});
|
|
4318
|
+
await drainPendingUnsubscribes(ctx).catch((err) => {
|
|
4319
|
+
console.error("mailery: pending-unsubscribe drain failed", err);
|
|
4320
|
+
});
|
|
3919
4321
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3920
4322
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3921
4323
|
});
|
|
@@ -4048,6 +4450,10 @@ var Mailer = class _Mailer {
|
|
|
4048
4450
|
* MAILER_FROM_NAME / MAILER_FROM_EMAIL
|
|
4049
4451
|
* MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
|
|
4050
4452
|
* MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
|
|
4453
|
+
* MAILER_SENDGRID_WEBHOOK_TOLERANCE — signed-webhook replay window, seconds
|
|
4454
|
+
* (default 300; '0' disables the check).
|
|
4455
|
+
* Unparseable values fall back to the
|
|
4456
|
+
* default rather than disabling it.
|
|
4051
4457
|
* MAILER_HOST_USERS_COLLECTION (default 'users')
|
|
4052
4458
|
* MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
|
|
4053
4459
|
* MAILER_HOST_USERS_ID_FIELD (default '_id')
|
|
@@ -4082,6 +4488,10 @@ var Mailer = class _Mailer {
|
|
|
4082
4488
|
providers.sendgrid = new SendGridProvider2({
|
|
4083
4489
|
apiKey: env.MAILER_SENDGRID_API_KEY,
|
|
4084
4490
|
webhookVerificationKey: env.MAILER_SENDGRID_WEBHOOK_KEY,
|
|
4491
|
+
// Left undefined when unset/blank so the provider default (300s) applies.
|
|
4492
|
+
// resolveWebhookToleranceSeconds() rejects NaN back to the default, so a
|
|
4493
|
+
// typo here can never silently switch the replay check off.
|
|
4494
|
+
webhookToleranceSeconds: env.MAILER_SENDGRID_WEBHOOK_TOLERANCE ? Number(env.MAILER_SENDGRID_WEBHOOK_TOLERANCE) : void 0,
|
|
4085
4495
|
sandbox: env.NODE_ENV !== "production"
|
|
4086
4496
|
});
|
|
4087
4497
|
}
|
|
@@ -4109,8 +4519,15 @@ var Mailer = class _Mailer {
|
|
|
4109
4519
|
}
|
|
4110
4520
|
static async init(input) {
|
|
4111
4521
|
const config = resolveConfig(input);
|
|
4112
|
-
if (!config.providers
|
|
4113
|
-
throw new Error(
|
|
4522
|
+
if (!resolveProvider(config.providers, config.defaultProvider)) {
|
|
4523
|
+
throw new Error(
|
|
4524
|
+
`defaultProvider "${config.defaultProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4525
|
+
);
|
|
4526
|
+
}
|
|
4527
|
+
if (config.defaultTransactionalProvider != null && !resolveProvider(config.providers, config.defaultTransactionalProvider)) {
|
|
4528
|
+
throw new Error(
|
|
4529
|
+
`defaultTransactionalProvider "${config.defaultTransactionalProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4530
|
+
);
|
|
4114
4531
|
}
|
|
4115
4532
|
if (config.varsAdapter) {
|
|
4116
4533
|
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
@@ -4252,38 +4669,14 @@ var Mailer = class _Mailer {
|
|
|
4252
4669
|
}
|
|
4253
4670
|
}
|
|
4254
4671
|
}
|
|
4672
|
+
/**
|
|
4673
|
+
* The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
|
|
4674
|
+
* drain (INVARIANT 8) replays a journaled opt-out through exactly this path
|
|
4675
|
+
* without holding a `Mailer` instance.
|
|
4676
|
+
*/
|
|
4255
4677
|
async unsubscribe(email, opts) {
|
|
4256
4678
|
const parsed = unsubscribeInputSchema.parse({ email, ...opts });
|
|
4257
|
-
|
|
4258
|
-
const now = /* @__PURE__ */ new Date();
|
|
4259
|
-
await this.collections.suppressions.updateOne(
|
|
4260
|
-
{ email: normalized, scope: parsed.scope },
|
|
4261
|
-
{
|
|
4262
|
-
$setOnInsert: {
|
|
4263
|
-
email: normalized,
|
|
4264
|
-
emailHash: sha256Hex(normalized),
|
|
4265
|
-
scope: parsed.scope,
|
|
4266
|
-
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
4267
|
-
reason: "unsubscribed",
|
|
4268
|
-
source: parsed.source,
|
|
4269
|
-
notes: parsed.notes ?? null,
|
|
4270
|
-
addedAt: now,
|
|
4271
|
-
expiresAt: null
|
|
4272
|
-
}
|
|
4273
|
-
},
|
|
4274
|
-
{ upsert: true }
|
|
4275
|
-
);
|
|
4276
|
-
await this.collections.subscriptions.updateOne(
|
|
4277
|
-
{ emailAtSubscribe: normalized },
|
|
4278
|
-
{
|
|
4279
|
-
$set: {
|
|
4280
|
-
status: "unsubscribed",
|
|
4281
|
-
unsubscribedAt: now,
|
|
4282
|
-
unsubscribeReason: parsed.reason,
|
|
4283
|
-
updatedAt: now
|
|
4284
|
-
}
|
|
4285
|
-
}
|
|
4286
|
-
);
|
|
4679
|
+
await applyUnsubscribe(this.collections, parsed);
|
|
4287
4680
|
}
|
|
4288
4681
|
async suppress(email, opts) {
|
|
4289
4682
|
const parsed = suppressInputSchema.parse({ email, ...opts });
|
|
@@ -4619,8 +5012,20 @@ var NullProvider = class {
|
|
|
4619
5012
|
status: "accepted"
|
|
4620
5013
|
};
|
|
4621
5014
|
}
|
|
5015
|
+
/**
|
|
5016
|
+
* Fails closed. This provider has no signing key, so it can never establish
|
|
5017
|
+
* that an inbound payload is authentic — and a method whose entire job is to
|
|
5018
|
+
* reject unauthenticated input must not answer "yes" by default. Returning
|
|
5019
|
+
* `true` here would mean any unsigned body posted to the webhook route is
|
|
5020
|
+
* accepted as genuine whenever the null provider is registered, which is
|
|
5021
|
+
* exactly the dev/staging configuration where that is easiest to miss.
|
|
5022
|
+
*
|
|
5023
|
+
* Nothing is lost: `parseWebhookEvents()` returns `[]`, so there was never
|
|
5024
|
+
* any inbound behaviour to preserve. To exercise the inbound half locally,
|
|
5025
|
+
* use a provider that actually signs (see `test/README.md`).
|
|
5026
|
+
*/
|
|
4622
5027
|
async verifyWebhook() {
|
|
4623
|
-
return
|
|
5028
|
+
return false;
|
|
4624
5029
|
}
|
|
4625
5030
|
parseWebhookEvents() {
|
|
4626
5031
|
return [];
|
|
@@ -5757,13 +6162,13 @@ function createAdminRouter(mailer, opts = {}) {
|
|
|
5757
6162
|
req.actor = getActor(req);
|
|
5758
6163
|
next();
|
|
5759
6164
|
});
|
|
5760
|
-
router.use("/api",
|
|
6165
|
+
router.use("/api", createAdminApiRouter(mailer, opts));
|
|
5761
6166
|
router.get(/.*/, (_req, res) => {
|
|
5762
6167
|
res.sendFile(path.join(spaDir, "index.html"));
|
|
5763
6168
|
});
|
|
5764
6169
|
return router;
|
|
5765
6170
|
}
|
|
5766
|
-
function
|
|
6171
|
+
function createAdminApiRouter(mailer, opts = {}) {
|
|
5767
6172
|
const r = Router();
|
|
5768
6173
|
const c = mailer.collections;
|
|
5769
6174
|
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
@@ -5960,7 +6365,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
5960
6365
|
asyncHandler(async (req, res) => {
|
|
5961
6366
|
const before = await c.flows.findOne({ slug: req.params.slug });
|
|
5962
6367
|
if (!before) return res.status(404).json({ error: "not_found" });
|
|
5963
|
-
|
|
6368
|
+
const now = /* @__PURE__ */ new Date();
|
|
6369
|
+
await c.flows.updateOne(
|
|
6370
|
+
{ _id: before._id },
|
|
6371
|
+
{ $set: { enabled: true, updatedAt: now, ...before.lastTriggerScanAt ? {} : { lastTriggerScanAt: now } } }
|
|
6372
|
+
);
|
|
5964
6373
|
await mailer.audit({
|
|
5965
6374
|
actor: req.actor,
|
|
5966
6375
|
action: "flow.resume",
|
|
@@ -6541,6 +6950,7 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6541
6950
|
if (!Array.isArray(draftSteps) || draftSteps.length === 0) {
|
|
6542
6951
|
return res.status(400).json({ error: "empty_flow", message: "flow has no steps to publish" });
|
|
6543
6952
|
}
|
|
6953
|
+
const enable = req.body?.enable !== false;
|
|
6544
6954
|
const nextVersion = (flow.version ?? 0) + 1;
|
|
6545
6955
|
const now = /* @__PURE__ */ new Date();
|
|
6546
6956
|
await c.flowVersions.insertOne({
|
|
@@ -6557,11 +6967,14 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6557
6967
|
$set: {
|
|
6558
6968
|
steps: draftSteps,
|
|
6559
6969
|
version: nextVersion,
|
|
6560
|
-
enabled: true,
|
|
6970
|
+
enabled: enable ? true : flow.enabled,
|
|
6561
6971
|
draft: null,
|
|
6562
6972
|
publishedAt: now,
|
|
6563
6973
|
publishedBy: req.actor,
|
|
6564
|
-
updatedAt: now
|
|
6974
|
+
updatedAt: now,
|
|
6975
|
+
// First enable: stamp the trigger watermark so the scan does not
|
|
6976
|
+
// replay every event since the flow document was created.
|
|
6977
|
+
...enable && !flow.lastTriggerScanAt ? { lastTriggerScanAt: now } : {}
|
|
6565
6978
|
}
|
|
6566
6979
|
}
|
|
6567
6980
|
);
|
|
@@ -6569,9 +6982,9 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6569
6982
|
actor: req.actor,
|
|
6570
6983
|
action: "flow.publish",
|
|
6571
6984
|
resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
|
|
6572
|
-
diffSummary: `Published v${nextVersion}`
|
|
6985
|
+
diffSummary: `Published v${nextVersion}${enable ? "" : " (left disabled)"}`
|
|
6573
6986
|
});
|
|
6574
|
-
return res.json({ ok: true, version: nextVersion });
|
|
6987
|
+
return res.json({ ok: true, version: nextVersion, enabled: enable ? true : flow.enabled });
|
|
6575
6988
|
})
|
|
6576
6989
|
);
|
|
6577
6990
|
r.delete(
|
|
@@ -6762,11 +7175,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6762
7175
|
if (cached) {
|
|
6763
7176
|
return res.json({ cached: true, status: "ready", score: cached });
|
|
6764
7177
|
}
|
|
6765
|
-
const
|
|
6766
|
-
const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
|
|
7178
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
6767
7179
|
if (!provider) {
|
|
6768
|
-
return res.status(
|
|
7180
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
6769
7181
|
}
|
|
7182
|
+
const { checkId, emailAddress } = await client.provisionCheck();
|
|
6770
7183
|
try {
|
|
6771
7184
|
await provider.send({
|
|
6772
7185
|
to: emailAddress,
|
|
@@ -7115,7 +7528,10 @@ function apiRouter(mailer, opts = {}) {
|
|
|
7115
7528
|
trackOpens: false,
|
|
7116
7529
|
trackClicks: false
|
|
7117
7530
|
});
|
|
7118
|
-
const provider = mailer.providers
|
|
7531
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
7532
|
+
if (!provider) {
|
|
7533
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
7534
|
+
}
|
|
7119
7535
|
const result = await provider.send({
|
|
7120
7536
|
to,
|
|
7121
7537
|
fromName: rendered.fromName,
|
|
@@ -7267,6 +7683,18 @@ function asyncHandler(fn) {
|
|
|
7267
7683
|
fn(req, res, next).catch(next);
|
|
7268
7684
|
};
|
|
7269
7685
|
}
|
|
7686
|
+
function unknownProviderError(mailer, tpl) {
|
|
7687
|
+
const override = tpl.providerOverride ?? null;
|
|
7688
|
+
const name = override ?? mailer.config.defaultProvider;
|
|
7689
|
+
const known = registeredProviderNames(mailer.providers);
|
|
7690
|
+
const source = override ? `template "${tpl.slug ?? "?"}" overrides the provider to "${name}"` : `the configured defaultProvider is "${name}"`;
|
|
7691
|
+
return {
|
|
7692
|
+
error: "provider_unknown",
|
|
7693
|
+
message: `${source}, but no such provider is registered. Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
|
|
7694
|
+
provider: name,
|
|
7695
|
+
registeredProviders: known
|
|
7696
|
+
};
|
|
7697
|
+
}
|
|
7270
7698
|
async function collectQueueCounts(mailer) {
|
|
7271
7699
|
const qs = Object.values(mailer.queues);
|
|
7272
7700
|
const sum = async (key) => {
|
|
@@ -7402,81 +7830,1690 @@ async function computeBroadcastStats(mailer, idFilter) {
|
|
|
7402
7830
|
}
|
|
7403
7831
|
return out;
|
|
7404
7832
|
}
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
)
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
);
|
|
7421
|
-
router.use("/unsub", express.urlencoded({ extended: false }));
|
|
7422
|
-
router.get("/open/:sendId.png", async (req, res) => {
|
|
7423
|
-
res.setHeader("Content-Type", "image/png");
|
|
7424
|
-
res.setHeader("Content-Length", String(PIXEL.length));
|
|
7425
|
-
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
7426
|
-
res.status(200).end(PIXEL);
|
|
7427
|
-
const id = req.params.sendId;
|
|
7428
|
-
if (!ObjectId.isValid(id)) return;
|
|
7429
|
-
const sendId = new ObjectId(id);
|
|
7833
|
+
|
|
7834
|
+
// src/server/api/wrap.ts
|
|
7835
|
+
var consoleRouteLogger = {
|
|
7836
|
+
error(fields, msg) {
|
|
7837
|
+
console.error(msg ?? "mailery: request failed", fields);
|
|
7838
|
+
},
|
|
7839
|
+
warn(fields, msg) {
|
|
7840
|
+
console.warn(msg ?? "mailery: request warning", fields);
|
|
7841
|
+
},
|
|
7842
|
+
info(fields, msg) {
|
|
7843
|
+
console.info(msg ?? "mailery: request", fields);
|
|
7844
|
+
}
|
|
7845
|
+
};
|
|
7846
|
+
function wrap(logger, handler) {
|
|
7847
|
+
return async (req, res, next) => {
|
|
7430
7848
|
try {
|
|
7431
|
-
|
|
7432
|
-
if (!send) return;
|
|
7433
|
-
await mailer.collections.sends.updateOne(
|
|
7434
|
-
{ _id: sendId },
|
|
7435
|
-
{
|
|
7436
|
-
$set: {
|
|
7437
|
-
openedAt: send.openedAt ?? /* @__PURE__ */ new Date(),
|
|
7438
|
-
status: "delivered"
|
|
7439
|
-
},
|
|
7440
|
-
$inc: { openCount: 1 }
|
|
7441
|
-
}
|
|
7442
|
-
);
|
|
7849
|
+
await handler(req, res, next);
|
|
7443
7850
|
} catch (err) {
|
|
7444
|
-
|
|
7851
|
+
logger.error?.({ err, path: req.originalUrl }, "mailery: request failed");
|
|
7852
|
+
if (res.headersSent) return;
|
|
7853
|
+
res.status(500).json({ error: "internal_error" });
|
|
7445
7854
|
}
|
|
7446
|
-
}
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7855
|
+
};
|
|
7856
|
+
}
|
|
7857
|
+
|
|
7858
|
+
// src/server/api/agent.ts
|
|
7859
|
+
init_vars();
|
|
7860
|
+
|
|
7861
|
+
// src/server/runner/arm.ts
|
|
7862
|
+
var FlowOperationError = class extends Error {
|
|
7863
|
+
constructor(code, message, status = 400) {
|
|
7864
|
+
super(message);
|
|
7865
|
+
this.code = code;
|
|
7866
|
+
this.status = status;
|
|
7867
|
+
this.name = "FlowOperationError";
|
|
7868
|
+
}
|
|
7869
|
+
code;
|
|
7870
|
+
status;
|
|
7871
|
+
};
|
|
7872
|
+
async function stampWatermarkIfNull(collections, flow, now = /* @__PURE__ */ new Date()) {
|
|
7873
|
+
if (flow.lastTriggerScanAt) return flow.lastTriggerScanAt;
|
|
7874
|
+
await collections.flows.updateOne(
|
|
7875
|
+
{ _id: flow._id, lastTriggerScanAt: null },
|
|
7876
|
+
{ $set: { lastTriggerScanAt: now, updatedAt: now } }
|
|
7877
|
+
);
|
|
7878
|
+
return now;
|
|
7879
|
+
}
|
|
7880
|
+
async function armFlow(mailer, slug, opts) {
|
|
7881
|
+
const c = mailer.collections;
|
|
7882
|
+
const flow = await c.flows.findOne({ slug });
|
|
7883
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
7884
|
+
if (!Array.isArray(flow.steps) || flow.steps.length === 0) {
|
|
7885
|
+
throw new FlowOperationError(
|
|
7886
|
+
"no_live_steps",
|
|
7887
|
+
`flow "${slug}" (v${flow.version}) has no published steps \u2014 publish it first; an empty flow completes every run instantly`,
|
|
7888
|
+
409
|
|
7454
7889
|
);
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7890
|
+
}
|
|
7891
|
+
const eventName = flow.trigger?.eventName ?? null;
|
|
7892
|
+
const now = /* @__PURE__ */ new Date();
|
|
7893
|
+
const watermark = opts.since ?? now;
|
|
7894
|
+
if (flow.enabled) {
|
|
7895
|
+
return {
|
|
7896
|
+
slug,
|
|
7897
|
+
version: flow.version,
|
|
7898
|
+
armed: false,
|
|
7899
|
+
alreadyEnabled: true,
|
|
7900
|
+
watermark: flow.lastTriggerScanAt ?? null,
|
|
7901
|
+
eventName,
|
|
7902
|
+
skippedEvents: 0,
|
|
7903
|
+
pendingEvents: 0
|
|
7904
|
+
};
|
|
7905
|
+
}
|
|
7906
|
+
const previous = flow.lastTriggerScanAt ?? flow.createdAt;
|
|
7907
|
+
const overlapFrom = new Date(watermark.getTime() - SCAN_OVERLAP_MS);
|
|
7908
|
+
const [skippedEvents, pendingEvents] = eventName ? await Promise.all([
|
|
7909
|
+
c.events.countDocuments({ name: eventName, createdAt: { $gt: previous, $lte: overlapFrom } }),
|
|
7910
|
+
c.events.countDocuments({ name: eventName, createdAt: { $gt: overlapFrom } })
|
|
7911
|
+
]) : [0, 0];
|
|
7912
|
+
const res = await c.flows.updateOne(
|
|
7913
|
+
{ _id: flow._id, enabled: false },
|
|
7914
|
+
{ $set: { enabled: true, lastTriggerScanAt: watermark, updatedAt: now } }
|
|
7915
|
+
);
|
|
7916
|
+
if (res.modifiedCount === 0) {
|
|
7917
|
+
const again = await c.flows.findOne({ _id: flow._id });
|
|
7918
|
+
return {
|
|
7919
|
+
slug,
|
|
7920
|
+
version: flow.version,
|
|
7921
|
+
armed: false,
|
|
7922
|
+
alreadyEnabled: !!again?.enabled,
|
|
7923
|
+
watermark: again?.lastTriggerScanAt ?? null,
|
|
7924
|
+
eventName,
|
|
7925
|
+
skippedEvents: 0,
|
|
7926
|
+
pendingEvents: 0
|
|
7927
|
+
};
|
|
7928
|
+
}
|
|
7929
|
+
await mailer.audit({
|
|
7930
|
+
actor: opts.actor,
|
|
7931
|
+
action: "flow.arm",
|
|
7932
|
+
resource: { collection: "mailer_flows", id: flow._id, slug },
|
|
7933
|
+
diffSummary: `Enabled v${flow.version} with lastTriggerScanAt=${watermark.toISOString()} (skipped ${skippedEvents} earlier ${eventName ?? "trigger"} event(s), ${pendingEvents} pending)`
|
|
7477
7934
|
});
|
|
7478
|
-
|
|
7479
|
-
|
|
7935
|
+
return {
|
|
7936
|
+
slug,
|
|
7937
|
+
version: flow.version,
|
|
7938
|
+
armed: true,
|
|
7939
|
+
alreadyEnabled: false,
|
|
7940
|
+
watermark,
|
|
7941
|
+
eventName,
|
|
7942
|
+
skippedEvents,
|
|
7943
|
+
pendingEvents
|
|
7944
|
+
};
|
|
7945
|
+
}
|
|
7946
|
+
async function disarmFlow(mailer, slug, actor) {
|
|
7947
|
+
const c = mailer.collections;
|
|
7948
|
+
const flow = await c.flows.findOne({ slug });
|
|
7949
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
7950
|
+
if (!flow.enabled) return { slug, disarmed: false };
|
|
7951
|
+
await c.flows.updateOne({ _id: flow._id }, { $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } });
|
|
7952
|
+
await mailer.audit({
|
|
7953
|
+
actor,
|
|
7954
|
+
action: "flow.pause",
|
|
7955
|
+
resource: { collection: "mailer_flows", id: flow._id, slug },
|
|
7956
|
+
diffSummary: "Disarmed (enabled: false); in-flight runs continue"
|
|
7957
|
+
});
|
|
7958
|
+
return { slug, disarmed: true };
|
|
7959
|
+
}
|
|
7960
|
+
function isCanaryGate(step) {
|
|
7961
|
+
const s = step;
|
|
7962
|
+
return !!s && s.type === "condition" && s.canaryGate === true;
|
|
7963
|
+
}
|
|
7964
|
+
function gateStep(tag) {
|
|
7965
|
+
return { type: "condition", test: { hasTag: tag }, ifFalse: "exit", canaryGate: true };
|
|
7966
|
+
}
|
|
7967
|
+
async function publishVersion(mailer, flow, steps, actor, summary) {
|
|
7968
|
+
const c = mailer.collections;
|
|
7969
|
+
const nextVersion = (flow.version ?? 0) + 1;
|
|
7970
|
+
const now = /* @__PURE__ */ new Date();
|
|
7971
|
+
await c.flowVersions.insertOne({
|
|
7972
|
+
flowId: flow._id,
|
|
7973
|
+
version: nextVersion,
|
|
7974
|
+
steps,
|
|
7975
|
+
trigger: flow.trigger,
|
|
7976
|
+
publishedAt: now,
|
|
7977
|
+
publishedBy: actor
|
|
7978
|
+
});
|
|
7979
|
+
await c.flows.updateOne(
|
|
7980
|
+
{ _id: flow._id },
|
|
7981
|
+
{ $set: { steps, version: nextVersion, draft: null, publishedAt: now, publishedBy: actor, updatedAt: now } }
|
|
7982
|
+
);
|
|
7983
|
+
await mailer.audit({
|
|
7984
|
+
actor,
|
|
7985
|
+
action: "flow.publish",
|
|
7986
|
+
resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
|
|
7987
|
+
diffSummary: `Published v${nextVersion}: ${summary}`
|
|
7988
|
+
});
|
|
7989
|
+
return { version: nextVersion };
|
|
7990
|
+
}
|
|
7991
|
+
async function gateFlow(mailer, slug, opts) {
|
|
7992
|
+
const tag = String(opts.tag ?? "").trim();
|
|
7993
|
+
if (!tag) throw new FlowOperationError("tag_required", "a canary tag is required");
|
|
7994
|
+
const flow = await mailer.collections.flows.findOne({ slug });
|
|
7995
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
7996
|
+
const live = Array.isArray(flow.steps) ? flow.steps : [];
|
|
7997
|
+
if (live.length === 0) {
|
|
7998
|
+
throw new FlowOperationError("no_live_steps", `flow "${slug}" has no published steps to gate`, 409);
|
|
7999
|
+
}
|
|
8000
|
+
if (isCanaryGate(live[0])) {
|
|
8001
|
+
throw new FlowOperationError(
|
|
8002
|
+
"already_gated",
|
|
8003
|
+
`flow "${slug}" v${flow.version} is already gated on "${live[0].test.hasTag}" \u2014 ungate first`,
|
|
8004
|
+
409
|
|
8005
|
+
);
|
|
8006
|
+
}
|
|
8007
|
+
await mailer.collections.flowVersions.updateOne(
|
|
8008
|
+
{ flowId: flow._id, version: flow.version },
|
|
8009
|
+
{
|
|
8010
|
+
$setOnInsert: {
|
|
8011
|
+
flowId: flow._id,
|
|
8012
|
+
version: flow.version,
|
|
8013
|
+
steps: live,
|
|
8014
|
+
trigger: flow.trigger,
|
|
8015
|
+
publishedAt: flow.publishedAt ?? flow.updatedAt ?? /* @__PURE__ */ new Date(),
|
|
8016
|
+
publishedBy: flow.publishedBy ?? "unknown"
|
|
8017
|
+
}
|
|
8018
|
+
},
|
|
8019
|
+
{ upsert: true }
|
|
8020
|
+
);
|
|
8021
|
+
const { version } = await publishVersion(
|
|
8022
|
+
mailer,
|
|
8023
|
+
flow,
|
|
8024
|
+
[gateStep(tag), ...live],
|
|
8025
|
+
opts.actor,
|
|
8026
|
+
`canary gate on tag "${tag}"`
|
|
8027
|
+
);
|
|
8028
|
+
return { slug, version, tag, enabled: flow.enabled };
|
|
8029
|
+
}
|
|
8030
|
+
async function ungateFlow(mailer, slug, opts) {
|
|
8031
|
+
const c = mailer.collections;
|
|
8032
|
+
const flow = await c.flows.findOne({ slug });
|
|
8033
|
+
if (!flow) throw new FlowOperationError("not_found", `no flow with slug "${slug}"`, 404);
|
|
8034
|
+
const live = Array.isArray(flow.steps) ? flow.steps : [];
|
|
8035
|
+
if (!isCanaryGate(live[0])) {
|
|
8036
|
+
throw new FlowOperationError("not_gated", `flow "${slug}" v${flow.version} is not gated`, 409);
|
|
8037
|
+
}
|
|
8038
|
+
const versions = await c.flowVersions.find({ flowId: flow._id }).sort({ version: -1 }).toArray();
|
|
8039
|
+
const clean = versions.find((v) => Array.isArray(v.steps) && v.steps.length > 0 && !isCanaryGate(v.steps[0]));
|
|
8040
|
+
if (!clean) {
|
|
8041
|
+
throw new FlowOperationError(
|
|
8042
|
+
"no_ungated_version",
|
|
8043
|
+
`flow "${slug}" has no ungated version in mailer_flow_versions to restore`,
|
|
8044
|
+
409
|
|
8045
|
+
);
|
|
8046
|
+
}
|
|
8047
|
+
const { version } = await publishVersion(
|
|
8048
|
+
mailer,
|
|
8049
|
+
flow,
|
|
8050
|
+
clean.steps,
|
|
8051
|
+
opts.actor,
|
|
8052
|
+
`restored steps of v${clean.version} (canary gate removed)`
|
|
8053
|
+
);
|
|
8054
|
+
return { slug, version, restoredFrom: clean.version, enabled: flow.enabled };
|
|
8055
|
+
}
|
|
8056
|
+
var MAX_STEPS = 1e3;
|
|
8057
|
+
async function simulateFlow(flow, contact, ctx, opts = {}) {
|
|
8058
|
+
const enteredAt = opts.at ?? /* @__PURE__ */ new Date();
|
|
8059
|
+
const steps = opts.steps ?? flow.steps ?? [];
|
|
8060
|
+
const eventName = flow.trigger?.eventName ?? "simulated";
|
|
8061
|
+
const eventProperties = opts.eventProperties ?? {};
|
|
8062
|
+
const reasons = [];
|
|
8063
|
+
if (!flow.enabled) reasons.push("flow is disabled (enabled: false)");
|
|
8064
|
+
const sub = await ctx.collections.subscriptions.findOne({ externalId: contact.externalId });
|
|
8065
|
+
if (!sub) reasons.push("contact has no subscription row \u2014 the trigger scan requires one");
|
|
8066
|
+
else if (sub.status !== "subscribed") reasons.push(`subscription status is "${sub.status}", not "subscribed"`);
|
|
8067
|
+
if (flow.trigger?.once) {
|
|
8068
|
+
const existing = await ctx.collections.flowRuns.findOne(
|
|
8069
|
+
{ externalId: contact.externalId, flowId: flow._id },
|
|
8070
|
+
{ projection: { _id: 1, status: 1 } }
|
|
8071
|
+
);
|
|
8072
|
+
if (existing) reasons.push(`trigger.once is true and the contact already has a run (${existing.status})`);
|
|
8073
|
+
}
|
|
8074
|
+
if (steps.length === 0) reasons.push("flow has no live steps \u2014 a run would complete immediately");
|
|
8075
|
+
const run = {
|
|
8076
|
+
_id: new ObjectId(),
|
|
8077
|
+
externalId: contact.externalId,
|
|
8078
|
+
flowId: flow._id,
|
|
8079
|
+
flowSlug: flow.slug,
|
|
8080
|
+
flowVersion: flow.version,
|
|
8081
|
+
emailAtEntry: contact.email,
|
|
8082
|
+
triggerEvent: { name: eventName, properties: eventProperties, occurredAt: enteredAt },
|
|
8083
|
+
triggerDedupeKey: null,
|
|
8084
|
+
enteredAt,
|
|
8085
|
+
status: "active",
|
|
8086
|
+
currentStepIndex: 0,
|
|
8087
|
+
currentBranchPath: [],
|
|
8088
|
+
nextActionAt: enteredAt,
|
|
8089
|
+
attemptsForCurrentStep: 0,
|
|
8090
|
+
history: [],
|
|
8091
|
+
exitedAt: null,
|
|
8092
|
+
exitReason: null,
|
|
8093
|
+
createdAt: enteredAt,
|
|
8094
|
+
updatedAt: enteredAt
|
|
8095
|
+
};
|
|
8096
|
+
const path3 = [];
|
|
8097
|
+
const sends = [];
|
|
8098
|
+
let list = steps;
|
|
8099
|
+
let index = 0;
|
|
8100
|
+
let branchPath = [];
|
|
8101
|
+
let t = enteredAt;
|
|
8102
|
+
let terminal = null;
|
|
8103
|
+
const record = (type, outcome, detail) => {
|
|
8104
|
+
path3.push({ at: t, stepIndex: index, branchPath: [...branchPath], type, outcome, ...detail ? { detail } : {} });
|
|
8105
|
+
};
|
|
8106
|
+
const predicateCtx = () => ({
|
|
8107
|
+
contact,
|
|
8108
|
+
run: { ...run, currentStepIndex: index, currentBranchPath: branchPath, nextActionAt: t },
|
|
8109
|
+
collections: ctx.collections,
|
|
8110
|
+
now: t,
|
|
8111
|
+
botFilter: ctx.config.botFilter
|
|
8112
|
+
});
|
|
8113
|
+
for (let guard = 0; guard < MAX_STEPS && !terminal; guard += 1) {
|
|
8114
|
+
const step = list[index];
|
|
8115
|
+
if (!step) {
|
|
8116
|
+
terminal = { kind: "completed", reason: "sequence_complete", at: t };
|
|
8117
|
+
break;
|
|
8118
|
+
}
|
|
8119
|
+
switch (step.type) {
|
|
8120
|
+
case "wait": {
|
|
8121
|
+
const ms = unitToMs2(step.value, step.unit);
|
|
8122
|
+
record("wait", "waited", { value: step.value, unit: step.unit, until: new Date(t.getTime() + ms) });
|
|
8123
|
+
t = new Date(t.getTime() + ms);
|
|
8124
|
+
index += 1;
|
|
8125
|
+
break;
|
|
8126
|
+
}
|
|
8127
|
+
case "condition": {
|
|
8128
|
+
const result = await evaluatePredicate(step.test, predicateCtx());
|
|
8129
|
+
if (result) {
|
|
8130
|
+
record("condition", "passed", { test: step.test, result });
|
|
8131
|
+
index += 1;
|
|
8132
|
+
} else if (step.ifFalse === "continue") {
|
|
8133
|
+
record("condition", "skipped_next", { test: step.test, result });
|
|
8134
|
+
index += 2;
|
|
8135
|
+
} else {
|
|
8136
|
+
record("condition", "exited", { test: step.test, result });
|
|
8137
|
+
terminal = { kind: "exited", reason: "condition_false", at: t };
|
|
8138
|
+
}
|
|
8139
|
+
break;
|
|
8140
|
+
}
|
|
8141
|
+
case "branch": {
|
|
8142
|
+
const result = await evaluatePredicate(step.test, predicateCtx());
|
|
8143
|
+
record("branch", result ? "branch_true" : "branch_false", { test: step.test, result });
|
|
8144
|
+
branchPath = [...branchPath, index, result ? "true" : "false", 0];
|
|
8145
|
+
list = result ? step.ifTrueSteps : step.ifFalseSteps;
|
|
8146
|
+
index = 0;
|
|
8147
|
+
break;
|
|
8148
|
+
}
|
|
8149
|
+
case "send": {
|
|
8150
|
+
let at = t;
|
|
8151
|
+
if (step.delivery) {
|
|
8152
|
+
at = computeDeliveryTime(t, step.delivery, contact.timezone);
|
|
8153
|
+
}
|
|
8154
|
+
if (at.getTime() > t.getTime() + 3e4) {
|
|
8155
|
+
record("send", "send_deferred", { templateSlug: step.templateSlug, delivery: step.delivery, until: at });
|
|
8156
|
+
t = at;
|
|
8157
|
+
}
|
|
8158
|
+
record("send", "send", { templateSlug: step.templateSlug });
|
|
8159
|
+
sends.push({ templateSlug: step.templateSlug, at: t, stepIndex: index, branchPath: [...branchPath] });
|
|
8160
|
+
index += 1;
|
|
8161
|
+
break;
|
|
8162
|
+
}
|
|
8163
|
+
case "tag":
|
|
8164
|
+
record("tag", "tagged", { addTags: step.addTags ?? [], removeTags: step.removeTags ?? [] });
|
|
8165
|
+
index += 1;
|
|
8166
|
+
break;
|
|
8167
|
+
case "fire_event":
|
|
8168
|
+
record("fire_event", "event_fired", { eventName: step.eventName });
|
|
8169
|
+
index += 1;
|
|
8170
|
+
break;
|
|
8171
|
+
case "webhook":
|
|
8172
|
+
record("webhook", "webhook", { url: step.url, method: step.method ?? "POST", note: "not called in simulation" });
|
|
8173
|
+
index += 1;
|
|
8174
|
+
break;
|
|
8175
|
+
case "exit":
|
|
8176
|
+
record("exit", "exited", { reason: step.reason ?? "exit_step" });
|
|
8177
|
+
terminal = { kind: "exited", reason: step.reason ?? "exit_step", at: t };
|
|
8178
|
+
break;
|
|
8179
|
+
}
|
|
8180
|
+
}
|
|
8181
|
+
if (!terminal) terminal = { kind: "truncated", reason: `stopped after ${MAX_STEPS} steps`, at: t };
|
|
8182
|
+
return {
|
|
8183
|
+
flow: { slug: flow.slug, version: flow.version, enabled: flow.enabled },
|
|
8184
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8185
|
+
enteredAt,
|
|
8186
|
+
wouldEnter: { ok: reasons.length === 0, reasons },
|
|
8187
|
+
path: path3,
|
|
8188
|
+
sends,
|
|
8189
|
+
terminal,
|
|
8190
|
+
durationMs: terminal.at.getTime() - enteredAt.getTime()
|
|
8191
|
+
};
|
|
8192
|
+
}
|
|
8193
|
+
function unitToMs2(value, unit) {
|
|
8194
|
+
const m = 6e4;
|
|
8195
|
+
switch (unit) {
|
|
8196
|
+
case "minutes":
|
|
8197
|
+
return value * m;
|
|
8198
|
+
case "hours":
|
|
8199
|
+
return value * 60 * m;
|
|
8200
|
+
case "days":
|
|
8201
|
+
return value * 24 * 60 * m;
|
|
8202
|
+
case "weeks":
|
|
8203
|
+
return value * 7 * 24 * 60 * m;
|
|
8204
|
+
}
|
|
8205
|
+
}
|
|
8206
|
+
|
|
8207
|
+
// src/server/api/agent.ts
|
|
8208
|
+
var VERSION = "0.16.0" ;
|
|
8209
|
+
var MIN_AGENT_TOKEN_LENGTH = 24;
|
|
8210
|
+
function createAgentRouter(mailer, opts) {
|
|
8211
|
+
if (!opts || !Array.isArray(opts.tokens) || opts.tokens.length === 0) {
|
|
8212
|
+
throw new Error("createAgentRouter: at least one bearer token is required \u2014 the agent API is never open");
|
|
8213
|
+
}
|
|
8214
|
+
for (const t of opts.tokens) {
|
|
8215
|
+
if (typeof t?.token !== "string" || t.token.length < MIN_AGENT_TOKEN_LENGTH) {
|
|
8216
|
+
throw new Error(
|
|
8217
|
+
`createAgentRouter: every token must be at least ${MIN_AGENT_TOKEN_LENGTH} characters (got ${t?.token?.length ?? 0})`
|
|
8218
|
+
);
|
|
8219
|
+
}
|
|
8220
|
+
if (typeof t.actor !== "string" || !t.actor.trim()) {
|
|
8221
|
+
throw new Error('createAgentRouter: every token needs an actor label, e.g. "agent:claude"');
|
|
8222
|
+
}
|
|
8223
|
+
}
|
|
8224
|
+
const logger = opts.logger ?? consoleRouteLogger;
|
|
8225
|
+
const isTestContact = testContactMatcher(opts.testContacts);
|
|
8226
|
+
const c = mailer.collections;
|
|
8227
|
+
const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
|
|
8228
|
+
const router = Router();
|
|
8229
|
+
router.use(express.json({ limit: "1mb" }));
|
|
8230
|
+
router.use(bearerAuth(opts.tokens));
|
|
8231
|
+
router.use("/api", createAdminApiRouter(mailer, { mailTesterClient: opts.mailTesterClient }));
|
|
8232
|
+
const actorOf = (req) => String(req.actor);
|
|
8233
|
+
function guardTestContact(res, contact) {
|
|
8234
|
+
if (!isTestContact) {
|
|
8235
|
+
res.status(403).json({
|
|
8236
|
+
error: "test_contacts_not_configured",
|
|
8237
|
+
message: "this route only acts on test contacts, and the router was constructed without a testContacts pattern"
|
|
8238
|
+
});
|
|
8239
|
+
return false;
|
|
8240
|
+
}
|
|
8241
|
+
if (!isTestContact(contact.email)) {
|
|
8242
|
+
res.status(403).json({
|
|
8243
|
+
error: "not_a_test_contact",
|
|
8244
|
+
message: `${contact.email} does not match the testContacts pattern`,
|
|
8245
|
+
externalId: contact.externalId
|
|
8246
|
+
});
|
|
8247
|
+
return false;
|
|
8248
|
+
}
|
|
8249
|
+
return true;
|
|
8250
|
+
}
|
|
8251
|
+
async function loadContact(res, externalId) {
|
|
8252
|
+
const contact = await mailer.adapter.getById(externalId);
|
|
8253
|
+
if (!contact) res.status(404).json({ error: "contact_not_found", externalId });
|
|
8254
|
+
return contact;
|
|
8255
|
+
}
|
|
8256
|
+
async function loadTemplate(res, slug) {
|
|
8257
|
+
const tpl = await c.templates.findOne({ slug });
|
|
8258
|
+
if (!tpl) res.status(404).json({ error: "template_not_found", slug });
|
|
8259
|
+
return tpl;
|
|
8260
|
+
}
|
|
8261
|
+
router.get("/", (req, res) => {
|
|
8262
|
+
res.json({
|
|
8263
|
+
service: "mailery-agent",
|
|
8264
|
+
version: VERSION,
|
|
8265
|
+
actor: actorOf(req),
|
|
8266
|
+
testContactsConfigured: !!isTestContact,
|
|
8267
|
+
docs: "https://jeffjassky.github.io/mailery/reference/agent-api",
|
|
8268
|
+
endpoints: ENDPOINTS
|
|
8269
|
+
});
|
|
8270
|
+
});
|
|
8271
|
+
router.post(
|
|
8272
|
+
"/templates/:slug/verify",
|
|
8273
|
+
wrap2(async (req, res) => {
|
|
8274
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8275
|
+
if (!tpl) return;
|
|
8276
|
+
const contact = await contactForRender(req, res);
|
|
8277
|
+
if (!contact) return;
|
|
8278
|
+
const report = await verifyTemplate(mailer, tpl, contact, {
|
|
8279
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8280
|
+
vars: objectOrUndefined(req.body?.vars),
|
|
8281
|
+
includeRendered: req.body?.includeRendered === true,
|
|
8282
|
+
varsSchema
|
|
8283
|
+
});
|
|
8284
|
+
res.status(200).json(report);
|
|
8285
|
+
})
|
|
8286
|
+
);
|
|
8287
|
+
router.post(
|
|
8288
|
+
"/templates/verify-all",
|
|
8289
|
+
wrap2(async (req, res) => {
|
|
8290
|
+
const slugs = Array.isArray(req.body?.slugs) ? req.body.slugs.map(String) : null;
|
|
8291
|
+
const contactIds = Array.isArray(req.body?.contactIds) ? req.body.contactIds.map(String) : [];
|
|
8292
|
+
if (contactIds.length === 0) {
|
|
8293
|
+
return res.status(400).json({ error: "validation_failed", message: "contactIds (non-empty array) is required" });
|
|
8294
|
+
}
|
|
8295
|
+
const templates = await c.templates.find(slugs ? { slug: { $in: slugs } } : {}).sort({ slug: 1 }).toArray();
|
|
8296
|
+
const contacts = [];
|
|
8297
|
+
for (const id of contactIds) {
|
|
8298
|
+
const found = await mailer.adapter.getById(id);
|
|
8299
|
+
if (!found) return res.status(404).json({ error: "contact_not_found", externalId: id });
|
|
8300
|
+
contacts.push(found);
|
|
8301
|
+
}
|
|
8302
|
+
const results = [];
|
|
8303
|
+
for (const tpl of templates) {
|
|
8304
|
+
for (const contact of contacts) {
|
|
8305
|
+
const report = await verifyTemplate(mailer, tpl, contact, {
|
|
8306
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8307
|
+
includeRendered: false,
|
|
8308
|
+
varsSchema
|
|
8309
|
+
});
|
|
8310
|
+
results.push({
|
|
8311
|
+
slug: tpl.slug,
|
|
8312
|
+
contactId: contact.externalId,
|
|
8313
|
+
ok: report.ok,
|
|
8314
|
+
failed: report.checks.filter((k) => k.status === "fail").map((k) => k.id),
|
|
8315
|
+
warned: report.checks.filter((k) => k.status === "warn").map((k) => k.id)
|
|
8316
|
+
});
|
|
8317
|
+
}
|
|
8318
|
+
}
|
|
8319
|
+
const failing = results.filter((r) => !r.ok);
|
|
8320
|
+
res.json({
|
|
8321
|
+
ok: failing.length === 0,
|
|
8322
|
+
templates: templates.length,
|
|
8323
|
+
contacts: contacts.length,
|
|
8324
|
+
verified: results.length,
|
|
8325
|
+
failing: failing.length,
|
|
8326
|
+
results
|
|
8327
|
+
});
|
|
8328
|
+
})
|
|
8329
|
+
);
|
|
8330
|
+
router.post(
|
|
8331
|
+
"/templates/:slug/render",
|
|
8332
|
+
wrap2(async (req, res) => {
|
|
8333
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8334
|
+
if (!tpl) return;
|
|
8335
|
+
const contact = await contactForRender(req, res);
|
|
8336
|
+
if (!contact) return;
|
|
8337
|
+
const out = await renderForContact(mailer, tpl, contact, {
|
|
8338
|
+
reason: "preview",
|
|
8339
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8340
|
+
vars: objectOrUndefined(req.body?.vars)
|
|
8341
|
+
});
|
|
8342
|
+
res.json({
|
|
8343
|
+
template: { slug: tpl.slug, kind: tpl.kind },
|
|
8344
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8345
|
+
subject: out.rendered.subject,
|
|
8346
|
+
preheader: out.rendered.preheader,
|
|
8347
|
+
fromName: out.rendered.fromName,
|
|
8348
|
+
fromEmail: out.rendered.fromEmail,
|
|
8349
|
+
replyTo: out.rendered.replyTo,
|
|
8350
|
+
html: out.rendered.html,
|
|
8351
|
+
plainText: out.rendered.plainText,
|
|
8352
|
+
resolvedVars: out.resolved,
|
|
8353
|
+
unsubscribeUrl: out.unsubscribeUrl
|
|
8354
|
+
});
|
|
8355
|
+
})
|
|
8356
|
+
);
|
|
8357
|
+
router.post(
|
|
8358
|
+
"/templates/:slug/send",
|
|
8359
|
+
wrap2(async (req, res) => {
|
|
8360
|
+
const tpl = await loadTemplate(res, String(req.params.slug));
|
|
8361
|
+
if (!tpl) return;
|
|
8362
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8363
|
+
if (!contactId) return res.status(400).json({ error: "validation_failed", message: "contactId is required" });
|
|
8364
|
+
const contact = await loadContact(res, contactId);
|
|
8365
|
+
if (!contact) return;
|
|
8366
|
+
if (!guardTestContact(res, contact)) return;
|
|
8367
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
8368
|
+
return res.status(409).json({ error: "not_published", message: "template has no published body" });
|
|
8369
|
+
}
|
|
8370
|
+
const dedupeKey = typeof req.body?.dedupeKey === "string" && req.body.dedupeKey ? String(req.body.dedupeKey) : `agent:${crypto2.randomUUID()}`;
|
|
8371
|
+
const { sendId } = await mailer.sendOneOff({
|
|
8372
|
+
templateSlug: tpl.slug,
|
|
8373
|
+
externalId: contact.externalId,
|
|
8374
|
+
dedupeKey,
|
|
8375
|
+
vars: objectOrUndefined(req.body?.vars)
|
|
8376
|
+
});
|
|
8377
|
+
const dispatchNow = req.body?.dispatch !== "queue";
|
|
8378
|
+
if (dispatchNow) {
|
|
8379
|
+
await dispatchSend(new ObjectId(sendId), mailer.getRunnerContext());
|
|
8380
|
+
}
|
|
8381
|
+
const send = await c.sends.findOne({ _id: new ObjectId(sendId) });
|
|
8382
|
+
await mailer.audit({
|
|
8383
|
+
actor: actorOf(req),
|
|
8384
|
+
action: "agent.send",
|
|
8385
|
+
resource: { collection: "mailer_sends", id: new ObjectId(sendId), slug: tpl.slug },
|
|
8386
|
+
diffSummary: `to=${contact.email} dispatch=${dispatchNow ? "now" : "queue"} dedupeKey=${dedupeKey}`
|
|
8387
|
+
});
|
|
8388
|
+
res.status(201).json({ sendId, dedupeKey, dispatched: dispatchNow, send: send ? sendSummary(send) : null });
|
|
8389
|
+
})
|
|
8390
|
+
);
|
|
8391
|
+
router.get(
|
|
8392
|
+
"/sends/:id/wait",
|
|
8393
|
+
wrap2(async (req, res) => {
|
|
8394
|
+
const id = String(req.params.id);
|
|
8395
|
+
if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8396
|
+
const target = String(req.query.status ?? "delivered");
|
|
8397
|
+
if (!WAIT_TARGETS.has(target)) {
|
|
8398
|
+
return res.status(400).json({ error: "validation_failed", message: `status must be one of ${[...WAIT_TARGETS].join(", ")}` });
|
|
8399
|
+
}
|
|
8400
|
+
const timeoutMs = Math.max(0, Math.min(Number(req.query.timeoutMs ?? 3e4) || 0, 55e3));
|
|
8401
|
+
const started = Date.now();
|
|
8402
|
+
let send = null;
|
|
8403
|
+
let reached = false;
|
|
8404
|
+
for (; ; ) {
|
|
8405
|
+
send = await c.sends.findOne({ _id: new ObjectId(id) });
|
|
8406
|
+
if (!send) return res.status(404).json({ error: "not_found" });
|
|
8407
|
+
reached = waitTargetReached(send, target);
|
|
8408
|
+
if (reached || Date.now() - started >= timeoutMs) break;
|
|
8409
|
+
await sleep2(1e3);
|
|
8410
|
+
}
|
|
8411
|
+
const webhookEvents = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).sort({ receivedAt: 1 }).limit(100).toArray() : [];
|
|
8412
|
+
res.json({ reached, target, waitedMs: Date.now() - started, send: sendSummary(send), webhookEvents });
|
|
8413
|
+
})
|
|
8414
|
+
);
|
|
8415
|
+
router.post(
|
|
8416
|
+
"/sends/:id/dispatch",
|
|
8417
|
+
wrap2(async (req, res) => {
|
|
8418
|
+
const id = String(req.params.id);
|
|
8419
|
+
if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8420
|
+
const send = await c.sends.findOne({ _id: new ObjectId(id) });
|
|
8421
|
+
if (!send) return res.status(404).json({ error: "not_found" });
|
|
8422
|
+
const contact = await loadContact(res, send.externalId);
|
|
8423
|
+
if (!contact) return;
|
|
8424
|
+
if (!guardTestContact(res, contact)) return;
|
|
8425
|
+
await dispatchSend(send._id, mailer.getRunnerContext());
|
|
8426
|
+
const after = await c.sends.findOne({ _id: send._id });
|
|
8427
|
+
res.json({ send: after ? sendSummary(after) : null });
|
|
8428
|
+
})
|
|
8429
|
+
);
|
|
8430
|
+
router.post(
|
|
8431
|
+
"/flows/:slug/simulate",
|
|
8432
|
+
wrap2(async (req, res) => {
|
|
8433
|
+
const flow = await c.flows.findOne({ slug: String(req.params.slug) });
|
|
8434
|
+
if (!flow) return res.status(404).json({ error: "flow_not_found", slug: String(req.params.slug) });
|
|
8435
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8436
|
+
if (!contactId) return res.status(400).json({ error: "validation_failed", message: "contactId is required" });
|
|
8437
|
+
const contact = await loadContact(res, contactId);
|
|
8438
|
+
if (!contact) return;
|
|
8439
|
+
let at;
|
|
8440
|
+
if (req.body?.at) {
|
|
8441
|
+
at = new Date(String(req.body.at));
|
|
8442
|
+
if (Number.isNaN(at.getTime())) return res.status(400).json({ error: "validation_failed", message: "at must be an ISO date" });
|
|
8443
|
+
}
|
|
8444
|
+
let steps = void 0;
|
|
8445
|
+
if (req.body?.version !== void 0) {
|
|
8446
|
+
const v = Number(req.body.version);
|
|
8447
|
+
if (v !== flow.version) {
|
|
8448
|
+
const snap = await c.flowVersions.findOne({ flowId: flow._id, version: v });
|
|
8449
|
+
if (!snap) return res.status(404).json({ error: "version_not_found", version: v });
|
|
8450
|
+
steps = snap.steps;
|
|
8451
|
+
}
|
|
8452
|
+
}
|
|
8453
|
+
const result = await simulateFlow(flow, contact, mailer.getRunnerContext(), {
|
|
8454
|
+
at,
|
|
8455
|
+
eventProperties: objectOrUndefined(req.body?.eventProperties),
|
|
8456
|
+
steps
|
|
8457
|
+
});
|
|
8458
|
+
res.json(result);
|
|
8459
|
+
})
|
|
8460
|
+
);
|
|
8461
|
+
router.post(
|
|
8462
|
+
"/flows/:slug/arm",
|
|
8463
|
+
wrap2(async (req, res) => {
|
|
8464
|
+
if (req.body?.confirm !== true) {
|
|
8465
|
+
return res.status(400).json({
|
|
8466
|
+
error: "confirm_required",
|
|
8467
|
+
message: 'arming enables a flow for every future matching event; pass {"confirm": true}'
|
|
8468
|
+
});
|
|
8469
|
+
}
|
|
8470
|
+
let since;
|
|
8471
|
+
if (req.body?.since) {
|
|
8472
|
+
since = new Date(String(req.body.since));
|
|
8473
|
+
if (Number.isNaN(since.getTime())) return res.status(400).json({ error: "validation_failed", message: "since must be an ISO date" });
|
|
8474
|
+
}
|
|
8475
|
+
const result = await armFlow(mailer, String(req.params.slug), { actor: actorOf(req), since });
|
|
8476
|
+
res.json(result);
|
|
8477
|
+
})
|
|
8478
|
+
);
|
|
8479
|
+
router.post(
|
|
8480
|
+
"/flows/:slug/disarm",
|
|
8481
|
+
wrap2(async (req, res) => {
|
|
8482
|
+
res.json(await disarmFlow(mailer, String(req.params.slug), actorOf(req)));
|
|
8483
|
+
})
|
|
8484
|
+
);
|
|
8485
|
+
router.post(
|
|
8486
|
+
"/flows/:slug/gate",
|
|
8487
|
+
wrap2(async (req, res) => {
|
|
8488
|
+
const tag = typeof req.body?.tag === "string" ? req.body.tag : "";
|
|
8489
|
+
res.json(await gateFlow(mailer, String(req.params.slug), { tag, actor: actorOf(req) }));
|
|
8490
|
+
})
|
|
8491
|
+
);
|
|
8492
|
+
router.post(
|
|
8493
|
+
"/flows/:slug/ungate",
|
|
8494
|
+
wrap2(async (req, res) => {
|
|
8495
|
+
res.json(await ungateFlow(mailer, String(req.params.slug), { actor: actorOf(req) }));
|
|
8496
|
+
})
|
|
8497
|
+
);
|
|
8498
|
+
router.get(
|
|
8499
|
+
"/runs",
|
|
8500
|
+
wrap2(async (req, res) => {
|
|
8501
|
+
const filter = {};
|
|
8502
|
+
if (typeof req.query.externalId === "string") filter.externalId = req.query.externalId;
|
|
8503
|
+
if (typeof req.query.flowSlug === "string") filter.flowSlug = req.query.flowSlug;
|
|
8504
|
+
if (typeof req.query.status === "string") filter.status = req.query.status;
|
|
8505
|
+
const limit = Math.min(Math.max(Number(req.query.limit ?? 50) || 50, 1), 200);
|
|
8506
|
+
const runs = await c.flowRuns.find(filter).sort({ enteredAt: -1 }).limit(limit).toArray();
|
|
8507
|
+
res.json(runs.map(runSummary));
|
|
8508
|
+
})
|
|
8509
|
+
);
|
|
8510
|
+
router.get(
|
|
8511
|
+
"/runs/:id",
|
|
8512
|
+
wrap2(async (req, res) => {
|
|
8513
|
+
const id = String(req.params.id);
|
|
8514
|
+
if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8515
|
+
const run = await c.flowRuns.findOne({ _id: new ObjectId(id) });
|
|
8516
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8517
|
+
const sends = await c.sends.find({ flowRunId: run._id }).sort({ queuedAt: 1 }).toArray();
|
|
8518
|
+
res.json({ run, sends: sends.map(sendSummary) });
|
|
8519
|
+
})
|
|
8520
|
+
);
|
|
8521
|
+
router.post(
|
|
8522
|
+
"/runs/:id/advance",
|
|
8523
|
+
wrap2(async (req, res) => {
|
|
8524
|
+
const id = String(req.params.id);
|
|
8525
|
+
if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8526
|
+
const run = await c.flowRuns.findOne({ _id: new ObjectId(id) });
|
|
8527
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8528
|
+
const contact = await loadContact(res, run.externalId);
|
|
8529
|
+
if (!contact) return;
|
|
8530
|
+
if (!guardTestContact(res, contact)) return;
|
|
8531
|
+
if (run.status !== "active") {
|
|
8532
|
+
return res.status(409).json({ error: "run_not_active", status: run.status, exitReason: run.exitReason });
|
|
8533
|
+
}
|
|
8534
|
+
const steps = Math.min(Math.max(Number(req.body?.steps ?? 1) || 1, 1), 50);
|
|
8535
|
+
const dispatch = req.body?.dispatch !== false;
|
|
8536
|
+
const ctx = mailer.getRunnerContext();
|
|
8537
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
8538
|
+
const historyBefore = run.history.length;
|
|
8539
|
+
const actor = actorOf(req);
|
|
8540
|
+
for (let i = 0; i < steps; i += 1) {
|
|
8541
|
+
const advanced = await advanceOnce(run._id, ctx, actor, c);
|
|
8542
|
+
if (!advanced) break;
|
|
8543
|
+
}
|
|
8544
|
+
const after = await c.flowRuns.findOne({ _id: run._id });
|
|
8545
|
+
const newSends = await c.sends.find({ flowRunId: run._id, queuedAt: { $gte: startedAt } }).toArray();
|
|
8546
|
+
if (dispatch) {
|
|
8547
|
+
for (const s of newSends) {
|
|
8548
|
+
if (s.status === "queued") await dispatchSend(s._id, ctx);
|
|
8549
|
+
}
|
|
8550
|
+
}
|
|
8551
|
+
const sends = await c.sends.find({ flowRunId: run._id, queuedAt: { $gte: startedAt } }).toArray();
|
|
8552
|
+
await mailer.audit({
|
|
8553
|
+
actor,
|
|
8554
|
+
action: "agent.run.advance",
|
|
8555
|
+
resource: { collection: "mailer_flow_runs", id: run._id, slug: run.flowSlug },
|
|
8556
|
+
diffSummary: `advanced ${steps} step(s) for ${contact.email}; ${sends.length} send(s) created`
|
|
8557
|
+
});
|
|
8558
|
+
res.json({
|
|
8559
|
+
run: after ? runSummary(after) : null,
|
|
8560
|
+
historyAdded: after ? after.history.slice(historyBefore) : [],
|
|
8561
|
+
sends: sends.map(sendSummary)
|
|
8562
|
+
});
|
|
8563
|
+
})
|
|
8564
|
+
);
|
|
8565
|
+
router.post(
|
|
8566
|
+
"/runs/:id/cancel",
|
|
8567
|
+
wrap2(async (req, res) => {
|
|
8568
|
+
const id = String(req.params.id);
|
|
8569
|
+
if (!ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
|
|
8570
|
+
const run = await c.flowRuns.findOne({ _id: new ObjectId(id) });
|
|
8571
|
+
if (!run) return res.status(404).json({ error: "not_found" });
|
|
8572
|
+
if (run.status !== "active") return res.status(409).json({ error: "run_not_active", status: run.status });
|
|
8573
|
+
const actor = actorOf(req);
|
|
8574
|
+
await exitFlowRun(run, `aborted_by_host:${actor}`, mailer.getRunnerContext());
|
|
8575
|
+
const cancelled = await c.sends.updateMany(
|
|
8576
|
+
{ flowRunId: run._id, status: "queued" },
|
|
8577
|
+
{ $set: { status: "cancelled", errorMessage: `cancelled: aborted_by_host:${actor}`, updatedAt: /* @__PURE__ */ new Date() } }
|
|
8578
|
+
);
|
|
8579
|
+
await mailer.audit({
|
|
8580
|
+
actor,
|
|
8581
|
+
action: "agent.run.cancel",
|
|
8582
|
+
resource: { collection: "mailer_flow_runs", id: run._id, slug: run.flowSlug },
|
|
8583
|
+
diffSummary: `cancelled run for ${run.externalId}; ${cancelled.modifiedCount} queued send(s) cancelled`
|
|
8584
|
+
});
|
|
8585
|
+
const after = await c.flowRuns.findOne({ _id: run._id });
|
|
8586
|
+
res.json({ run: after ? runSummary(after) : null, cancelledSends: cancelled.modifiedCount });
|
|
8587
|
+
})
|
|
8588
|
+
);
|
|
8589
|
+
router.post(
|
|
8590
|
+
"/events",
|
|
8591
|
+
wrap2(async (req, res) => {
|
|
8592
|
+
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
|
8593
|
+
const externalId = typeof req.body?.externalId === "string" ? req.body.externalId : "";
|
|
8594
|
+
if (!name || !externalId) {
|
|
8595
|
+
return res.status(400).json({ error: "validation_failed", message: "name and externalId are required" });
|
|
8596
|
+
}
|
|
8597
|
+
const contact = await loadContact(res, externalId);
|
|
8598
|
+
if (!contact) return;
|
|
8599
|
+
if (!guardTestContact(res, contact)) return;
|
|
8600
|
+
const dedupeKey = typeof req.body?.dedupeKey === "string" && req.body.dedupeKey ? req.body.dedupeKey : void 0;
|
|
8601
|
+
try {
|
|
8602
|
+
await mailer.fire(name, externalId, objectOrUndefined(req.body?.properties) ?? {}, dedupeKey);
|
|
8603
|
+
} catch (err) {
|
|
8604
|
+
return res.status(400).json({ error: "fire_failed", message: String(err?.message ?? err) });
|
|
8605
|
+
}
|
|
8606
|
+
const latest = await c.events.findOne({ externalId, name }, { sort: { createdAt: -1 } });
|
|
8607
|
+
await mailer.audit({
|
|
8608
|
+
actor: actorOf(req),
|
|
8609
|
+
action: "agent.event.fire",
|
|
8610
|
+
resource: { collection: "mailer_events", id: latest?._id },
|
|
8611
|
+
diffSummary: `${name} for ${contact.email}${dedupeKey ? ` key=${dedupeKey}` : ""}`
|
|
8612
|
+
});
|
|
8613
|
+
res.status(201).json({ ok: true, event: latest });
|
|
8614
|
+
})
|
|
8615
|
+
);
|
|
8616
|
+
router.get(
|
|
8617
|
+
"/contacts/by-email/:email",
|
|
8618
|
+
wrap2(async (req, res) => {
|
|
8619
|
+
const contact = await mailer.adapter.getByEmail(String(req.params.email).toLowerCase());
|
|
8620
|
+
if (!contact) return res.status(404).json({ error: "contact_not_found", email: req.params.email });
|
|
8621
|
+
res.json(await contactDetail(contact));
|
|
8622
|
+
})
|
|
8623
|
+
);
|
|
8624
|
+
router.get(
|
|
8625
|
+
"/contacts/:externalId",
|
|
8626
|
+
wrap2(async (req, res) => {
|
|
8627
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8628
|
+
if (!contact) return;
|
|
8629
|
+
res.json(await contactDetail(contact));
|
|
8630
|
+
})
|
|
8631
|
+
);
|
|
8632
|
+
router.get(
|
|
8633
|
+
"/contacts/:externalId/unsubscribe-url",
|
|
8634
|
+
wrap2(async (req, res) => {
|
|
8635
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8636
|
+
if (!contact) return;
|
|
8637
|
+
if (!guardTestContact(res, contact)) return;
|
|
8638
|
+
res.json({ contact: { externalId: contact.externalId, email: contact.email }, unsubscribeUrl: unsubscribeUrlFor(mailer, contact.email) });
|
|
8639
|
+
})
|
|
8640
|
+
);
|
|
8641
|
+
router.post(
|
|
8642
|
+
"/contacts/:externalId/subscribe",
|
|
8643
|
+
wrap2(async (req, res) => {
|
|
8644
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8645
|
+
if (!contact) return;
|
|
8646
|
+
if (!guardTestContact(res, contact)) return;
|
|
8647
|
+
await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent" });
|
|
8648
|
+
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8649
|
+
await mailer.audit({
|
|
8650
|
+
actor: actorOf(req),
|
|
8651
|
+
action: "agent.contact.subscribe",
|
|
8652
|
+
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8653
|
+
diffSummary: contact.email
|
|
8654
|
+
});
|
|
8655
|
+
res.json({ subscription: sub });
|
|
8656
|
+
})
|
|
8657
|
+
);
|
|
8658
|
+
router.post(
|
|
8659
|
+
"/contacts/:externalId/unsubscribe",
|
|
8660
|
+
wrap2(async (req, res) => {
|
|
8661
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8662
|
+
if (!contact) return;
|
|
8663
|
+
if (!guardTestContact(res, contact)) return;
|
|
8664
|
+
await mailer.unsubscribe(contact.email, { scope: "marketing", reason: "user_request", source: "agent" });
|
|
8665
|
+
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8666
|
+
await mailer.audit({
|
|
8667
|
+
actor: actorOf(req),
|
|
8668
|
+
action: "agent.contact.unsubscribe",
|
|
8669
|
+
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8670
|
+
diffSummary: contact.email
|
|
8671
|
+
});
|
|
8672
|
+
res.json({ subscription: sub });
|
|
8673
|
+
})
|
|
8674
|
+
);
|
|
8675
|
+
router.post(
|
|
8676
|
+
"/contacts/:externalId/reset",
|
|
8677
|
+
wrap2(async (req, res) => {
|
|
8678
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8679
|
+
if (!contact) return;
|
|
8680
|
+
if (!guardTestContact(res, contact)) return;
|
|
8681
|
+
const b = req.body ?? {};
|
|
8682
|
+
const eventFilter = { externalId: contact.externalId };
|
|
8683
|
+
if (Array.isArray(b.events)) eventFilter.name = { $in: b.events.map(String) };
|
|
8684
|
+
const doRuns = b.runs !== false;
|
|
8685
|
+
const doSends = b.sends !== false;
|
|
8686
|
+
const doEvents = b.events !== false;
|
|
8687
|
+
const doSuppressions = b.suppressions !== false;
|
|
8688
|
+
const doSubscribe = b.subscribe !== false;
|
|
8689
|
+
const removed = { runs: 0, sends: 0, events: 0, suppressions: 0 };
|
|
8690
|
+
if (doRuns) removed.runs = (await c.flowRuns.deleteMany({ externalId: contact.externalId })).deletedCount;
|
|
8691
|
+
if (doSends) removed.sends = (await c.sends.deleteMany({ externalId: contact.externalId })).deletedCount;
|
|
8692
|
+
if (doEvents) removed.events = (await c.events.deleteMany(eventFilter)).deletedCount;
|
|
8693
|
+
if (doSuppressions) removed.suppressions = (await c.suppressions.deleteMany({ email: contact.email })).deletedCount;
|
|
8694
|
+
if (doSubscribe) await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent-reset" });
|
|
8695
|
+
const subscription = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8696
|
+
await mailer.audit({
|
|
8697
|
+
actor: actorOf(req),
|
|
8698
|
+
action: "agent.contact.reset",
|
|
8699
|
+
resource: { collection: "mailer_subscriptions", id: subscription?._id },
|
|
8700
|
+
diffSummary: `${contact.email}: removed ${removed.runs} run(s), ${removed.sends} send(s), ${removed.events} event(s), ${removed.suppressions} suppression(s)${doSubscribe ? "; resubscribed" : ""}`
|
|
8701
|
+
});
|
|
8702
|
+
res.json({ contact: { externalId: contact.externalId, email: contact.email }, removed, subscription });
|
|
8703
|
+
})
|
|
8704
|
+
);
|
|
8705
|
+
router.post(
|
|
8706
|
+
"/tick",
|
|
8707
|
+
wrap2(async (_req, res) => {
|
|
8708
|
+
const started = Date.now();
|
|
8709
|
+
await runTick(mailer.getRunnerContext());
|
|
8710
|
+
res.json({ ok: true, ms: Date.now() - started });
|
|
8711
|
+
})
|
|
8712
|
+
);
|
|
8713
|
+
router.get(
|
|
8714
|
+
"/webhooks/status",
|
|
8715
|
+
wrap2(async (_req, res) => {
|
|
8716
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
8717
|
+
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
|
|
8718
|
+
const [last, byType24h, total7d, unprocessed] = await Promise.all([
|
|
8719
|
+
c.webhookEvents.findOne({}, { sort: { receivedAt: -1 }, projection: { receivedAt: 1, provider: 1, normalizedType: 1 } }),
|
|
8720
|
+
c.webhookEvents.aggregate([{ $match: { receivedAt: { $gte: dayAgo } } }, { $group: { _id: "$normalizedType", n: { $sum: 1 } } }]).toArray(),
|
|
8721
|
+
c.webhookEvents.countDocuments({ receivedAt: { $gte: weekAgo } }),
|
|
8722
|
+
c.webhookEvents.countDocuments({ processed: false })
|
|
8723
|
+
]);
|
|
8724
|
+
res.json({
|
|
8725
|
+
providers: Object.keys(mailer.providers),
|
|
8726
|
+
lastReceivedAt: last?.receivedAt ?? null,
|
|
8727
|
+
lastProvider: last?.provider ?? null,
|
|
8728
|
+
last24h: Object.fromEntries(byType24h.map((r) => [r._id, r.n])),
|
|
8729
|
+
last7d: total7d,
|
|
8730
|
+
unprocessed,
|
|
8731
|
+
ingestPath: `${mailer.config.publicUrl}/m/webhooks/<provider>`
|
|
8732
|
+
});
|
|
8733
|
+
})
|
|
8734
|
+
);
|
|
8735
|
+
router.get(
|
|
8736
|
+
"/status",
|
|
8737
|
+
wrap2(async (_req, res) => {
|
|
8738
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
8739
|
+
const [flows, templates, subscribed, suppressions, activeRuns, sends24h, webhooks24h, lastWebhook, healthDocs, setup] = await Promise.all([
|
|
8740
|
+
c.flows.find({}, { projection: { slug: 1, enabled: 1, version: 1, lastTriggerScanAt: 1, steps: 1, trigger: 1 } }).sort({ slug: 1 }).toArray(),
|
|
8741
|
+
c.templates.find({}, { projection: { slug: 1, kind: 1, publishedAt: 1, "body.html": 1, fromEmail: 1 } }).sort({ slug: 1 }).toArray(),
|
|
8742
|
+
c.subscriptions.countDocuments({ status: "subscribed" }),
|
|
8743
|
+
c.suppressions.estimatedDocumentCount(),
|
|
8744
|
+
c.flowRuns.countDocuments({ status: "active" }),
|
|
8745
|
+
c.sends.aggregate([{ $match: { queuedAt: { $gte: dayAgo } } }, { $group: { _id: "$status", n: { $sum: 1 } } }]).toArray(),
|
|
8746
|
+
c.webhookEvents.countDocuments({ receivedAt: { $gte: dayAgo } }),
|
|
8747
|
+
c.webhookEvents.findOne({}, { sort: { receivedAt: -1 }, projection: { receivedAt: 1 } }),
|
|
8748
|
+
c.health.find({}).limit(500).toArray(),
|
|
8749
|
+
runSetupChecks(mailer)
|
|
8750
|
+
]);
|
|
8751
|
+
const activeByFlow = await c.flowRuns.aggregate([{ $match: { status: "active" } }, { $group: { _id: "$flowSlug", n: { $sum: 1 } } }]).toArray();
|
|
8752
|
+
const activeMap = new Map(activeByFlow.map((r) => [r._id, r.n]));
|
|
8753
|
+
res.json({
|
|
8754
|
+
version: VERSION,
|
|
8755
|
+
now: /* @__PURE__ */ new Date(),
|
|
8756
|
+
testContactsConfigured: !!isTestContact,
|
|
8757
|
+
setup,
|
|
8758
|
+
health: { status: healthDocs.length ? effectiveOverallStatus(healthDocs) : null, aggregate: healthDocs.find((d) => d._id === HEALTH_AGG_ID) ?? null },
|
|
8759
|
+
flows: flows.map((f) => ({
|
|
8760
|
+
slug: f.slug,
|
|
8761
|
+
enabled: f.enabled,
|
|
8762
|
+
version: f.version,
|
|
8763
|
+
lastTriggerScanAt: f.lastTriggerScanAt ?? null,
|
|
8764
|
+
trigger: f.trigger,
|
|
8765
|
+
liveSteps: Array.isArray(f.steps) ? f.steps.length : 0,
|
|
8766
|
+
gated: Array.isArray(f.steps) && isCanaryGate(f.steps[0]) ? f.steps[0].test.hasTag : null,
|
|
8767
|
+
activeRuns: activeMap.get(f.slug) ?? 0
|
|
8768
|
+
})),
|
|
8769
|
+
templates: templates.map((t) => ({
|
|
8770
|
+
slug: t.slug,
|
|
8771
|
+
kind: t.kind,
|
|
8772
|
+
fromEmail: t.fromEmail,
|
|
8773
|
+
published: !!(t.body && t.body.html),
|
|
8774
|
+
publishedAt: t.publishedAt ?? null
|
|
8775
|
+
})),
|
|
8776
|
+
counts: {
|
|
8777
|
+
subscribed,
|
|
8778
|
+
suppressions,
|
|
8779
|
+
activeRuns,
|
|
8780
|
+
sendsLast24h: Object.fromEntries(sends24h.map((r) => [r._id, r.n])),
|
|
8781
|
+
webhookEventsLast24h: webhooks24h,
|
|
8782
|
+
lastWebhookAt: lastWebhook?.receivedAt ?? null
|
|
8783
|
+
}
|
|
8784
|
+
});
|
|
8785
|
+
})
|
|
8786
|
+
);
|
|
8787
|
+
router.use((req, res) => {
|
|
8788
|
+
res.status(404).json({ error: "not_found", message: `no agent route ${req.method} ${req.path}; GET / lists them` });
|
|
8789
|
+
});
|
|
8790
|
+
router.use((err, req, res, _next) => {
|
|
8791
|
+
if (err instanceof FlowOperationError) {
|
|
8792
|
+
return res.status(err.status).json({ error: err.code, message: err.message });
|
|
8793
|
+
}
|
|
8794
|
+
if (err?.name === "ZodError") {
|
|
8795
|
+
return res.status(400).json({ error: "validation_failed", issues: err.issues });
|
|
8796
|
+
}
|
|
8797
|
+
logger.error?.({ err: String(err?.message ?? err), path: req.path, method: req.method }, "mailery agent: request failed");
|
|
8798
|
+
if (res.headersSent) return;
|
|
8799
|
+
res.status(500).json({ error: "internal", message: String(err?.message ?? err) });
|
|
8800
|
+
});
|
|
8801
|
+
return router;
|
|
8802
|
+
async function contactForRender(req, res) {
|
|
8803
|
+
const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : "";
|
|
8804
|
+
if (contactId) return loadContact(res, contactId);
|
|
8805
|
+
const sample = req.body?.sampleContact;
|
|
8806
|
+
if (sample && typeof sample === "object" && typeof sample.email === "string") {
|
|
8807
|
+
return {
|
|
8808
|
+
externalId: String(sample.externalId ?? "sample-contact"),
|
|
8809
|
+
email: sample.email,
|
|
8810
|
+
tags: Array.isArray(sample.tags) ? sample.tags.map(String) : [],
|
|
8811
|
+
fields: sample.fields && typeof sample.fields === "object" ? sample.fields : {},
|
|
8812
|
+
timezone: typeof sample.timezone === "string" ? sample.timezone : void 0
|
|
8813
|
+
};
|
|
8814
|
+
}
|
|
8815
|
+
res.status(400).json({ error: "validation_failed", message: "contactId (or a sampleContact with an email) is required" });
|
|
8816
|
+
return null;
|
|
8817
|
+
}
|
|
8818
|
+
async function contactDetail(contact) {
|
|
8819
|
+
const [subscription, recentEvents, recentSends, runs, suppressions] = await Promise.all([
|
|
8820
|
+
c.subscriptions.findOne({ externalId: contact.externalId }),
|
|
8821
|
+
c.events.find({ externalId: contact.externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
|
|
8822
|
+
c.sends.find({ externalId: contact.externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
|
|
8823
|
+
c.flowRuns.find({ externalId: contact.externalId }).sort({ enteredAt: -1 }).limit(50).toArray(),
|
|
8824
|
+
c.suppressions.find({ email: contact.email }).toArray()
|
|
8825
|
+
]);
|
|
8826
|
+
return {
|
|
8827
|
+
contact,
|
|
8828
|
+
isTestContact: isTestContact ? isTestContact(contact.email) : null,
|
|
8829
|
+
subscription,
|
|
8830
|
+
suppressions,
|
|
8831
|
+
recentEvents,
|
|
8832
|
+
recentSends: recentSends.map(sendSummary),
|
|
8833
|
+
runs: runs.map(runSummary)
|
|
8834
|
+
};
|
|
8835
|
+
}
|
|
8836
|
+
}
|
|
8837
|
+
var PLACEHOLDER_RE = /\{\{[^{}]*\}\}|\{\{\{[^{}]*\}\}\}/g;
|
|
8838
|
+
var HREF_RE = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
8839
|
+
var GMAIL_CLIP_BYTES = 102 * 1024;
|
|
8840
|
+
async function verifyTemplate(mailer, tpl, contact, opts = {}) {
|
|
8841
|
+
const checks = [];
|
|
8842
|
+
const push = (id, status, detail) => checks.push(detail === void 0 ? { id, status } : { id, status, detail });
|
|
8843
|
+
const base = {
|
|
8844
|
+
template: { slug: tpl.slug, kind: tpl.kind, name: tpl.name },
|
|
8845
|
+
contact: { externalId: contact.externalId, email: contact.email }
|
|
8846
|
+
};
|
|
8847
|
+
const finish = (rendered2, links2) => ({
|
|
8848
|
+
ok: checks.every((k) => k.status !== "fail"),
|
|
8849
|
+
...base,
|
|
8850
|
+
checks,
|
|
8851
|
+
links: { total: links2.length, sample: links2.slice(0, 50) },
|
|
8852
|
+
rendered: rendered2 ? opts.includeRendered ? {
|
|
8853
|
+
subject: rendered2.subject,
|
|
8854
|
+
preheader: rendered2.preheader,
|
|
8855
|
+
html: rendered2.html,
|
|
8856
|
+
plainText: rendered2.plainText,
|
|
8857
|
+
fromEmail: rendered2.fromEmail,
|
|
8858
|
+
fromName: rendered2.fromName,
|
|
8859
|
+
replyTo: rendered2.replyTo
|
|
8860
|
+
} : {
|
|
8861
|
+
subject: rendered2.subject,
|
|
8862
|
+
preheader: rendered2.preheader,
|
|
8863
|
+
htmlBytes: Buffer.byteLength(rendered2.html, "utf8"),
|
|
8864
|
+
textLength: rendered2.plainText.trim().length,
|
|
8865
|
+
fromEmail: rendered2.fromEmail
|
|
8866
|
+
} : null
|
|
8867
|
+
});
|
|
8868
|
+
if (!tpl.body?.html && !tpl.body?.mjml) {
|
|
8869
|
+
push("published", "fail", "template has no published body (body.html and body.mjml are empty)");
|
|
8870
|
+
return finish(null, []);
|
|
8871
|
+
}
|
|
8872
|
+
push("published", "pass", { publishedAt: tpl.publishedAt ?? null });
|
|
8873
|
+
let out;
|
|
8874
|
+
try {
|
|
8875
|
+
out = await renderForContact(mailer, tpl, contact, {
|
|
8876
|
+
reason: "test",
|
|
8877
|
+
eventProperties: opts.eventProperties,
|
|
8878
|
+
vars: opts.vars
|
|
8879
|
+
});
|
|
8880
|
+
push("vars_resolved", "pass", { keys: Object.keys(out.resolved) });
|
|
8881
|
+
push("render", "pass");
|
|
8882
|
+
} catch (err) {
|
|
8883
|
+
const message = String(err?.message ?? err);
|
|
8884
|
+
push(message.startsWith("varsAdapter") ? "vars_resolved" : "render", "fail", message);
|
|
8885
|
+
return finish(null, []);
|
|
8886
|
+
}
|
|
8887
|
+
const { rendered, unsubscribeUrl } = out;
|
|
8888
|
+
const leftovers = /* @__PURE__ */ new Set();
|
|
8889
|
+
for (const part of [rendered.subject, rendered.preheader, rendered.html, rendered.plainText]) {
|
|
8890
|
+
for (const m of part.matchAll(PLACEHOLDER_RE)) leftovers.add(m[0]);
|
|
8891
|
+
}
|
|
8892
|
+
push("unresolved_placeholders", leftovers.size ? "fail" : "pass", leftovers.size ? { placeholders: [...leftovers] } : void 0);
|
|
8893
|
+
const referenced = referencedPaths(
|
|
8894
|
+
[tpl.subject, tpl.preheader, tpl.body.html, tpl.body.plainText, tpl.body.mjml ?? ""].join("\n"),
|
|
8895
|
+
Object.keys(mailer.config.handlebarsHelpers ?? {})
|
|
8896
|
+
);
|
|
8897
|
+
const missing = [];
|
|
8898
|
+
const empty = [];
|
|
8899
|
+
for (const path3 of referenced) {
|
|
8900
|
+
const value = lookupPath(out.context, path3);
|
|
8901
|
+
if (value === void 0) {
|
|
8902
|
+
const root = path3.split(".")[0];
|
|
8903
|
+
if (RESERVED_VAR_KEYS.includes(root)) empty.push(path3);
|
|
8904
|
+
else missing.push(path3);
|
|
8905
|
+
} else if (value === null || value === "") {
|
|
8906
|
+
empty.push(path3);
|
|
8907
|
+
}
|
|
8908
|
+
}
|
|
8909
|
+
push("unknown_variables", missing.length ? "fail" : "pass", { referenced: referenced.length, missing });
|
|
8910
|
+
push("empty_variables", empty.length ? "warn" : "pass", { empty });
|
|
8911
|
+
const links = extractLinks(rendered.html);
|
|
8912
|
+
const invalid = links.filter((l) => !/^(https?:\/\/|mailto:|tel:)/i.test(l));
|
|
8913
|
+
push("links_absolute", invalid.length ? "fail" : "pass", { total: links.length, invalid });
|
|
8914
|
+
if (tpl.kind === "marketing") {
|
|
8915
|
+
push("unsubscribe_link", rendered.html.includes(unsubscribeUrl) ? "pass" : "fail", {
|
|
8916
|
+
hint: "a marketing template must reference {{unsubscribeUrl}} in its body"
|
|
8917
|
+
});
|
|
8918
|
+
if (mailer.config.senderAddress) {
|
|
8919
|
+
push("sender_address", rendered.html.includes(mailer.config.senderAddress) ? "pass" : "fail", {
|
|
8920
|
+
hint: "CAN-SPAM: reference {{senderAddress}} in the footer"
|
|
8921
|
+
});
|
|
8922
|
+
}
|
|
8923
|
+
}
|
|
8924
|
+
const text = rendered.plainText.trim();
|
|
8925
|
+
push("plain_text", text.length === 0 ? "fail" : text.length < 40 ? "warn" : "pass", { length: text.length });
|
|
8926
|
+
const subject = rendered.subject.trim();
|
|
8927
|
+
push("subject", subject.length === 0 ? "fail" : subject.length > 78 ? "warn" : "pass", { length: subject.length });
|
|
8928
|
+
if (mailer.config.senderDomains && Object.keys(mailer.config.senderDomains).length > 0) {
|
|
8929
|
+
const v = validateSenderDomain(rendered.fromEmail, tpl.kind, mailer.config.senderDomains);
|
|
8930
|
+
push("from_domain", v.ok ? "pass" : "fail", v.ok ? { fromEmail: rendered.fromEmail } : { fromEmail: rendered.fromEmail, code: v.code, reason: v.reason });
|
|
8931
|
+
}
|
|
8932
|
+
const bytes = Buffer.byteLength(rendered.html, "utf8");
|
|
8933
|
+
push("html_size", bytes > GMAIL_CLIP_BYTES ? "warn" : "pass", { bytes, clipAt: GMAIL_CLIP_BYTES });
|
|
8934
|
+
const lint = lintTemplate(
|
|
8935
|
+
{
|
|
8936
|
+
subject: tpl.subject,
|
|
8937
|
+
preheader: tpl.preheader,
|
|
8938
|
+
mjml: tpl.body.mjml ?? "",
|
|
8939
|
+
editorJson: tpl.body.editorJson ?? void 0,
|
|
8940
|
+
html: tpl.body.html,
|
|
8941
|
+
plainText: tpl.body.plainText,
|
|
8942
|
+
kind: tpl.kind,
|
|
8943
|
+
fromEmail: tpl.fromEmail
|
|
8944
|
+
},
|
|
8945
|
+
{ senderDomains: mailer.config.senderDomains, varsJsonSchema: opts.varsSchema ?? null }
|
|
8946
|
+
);
|
|
8947
|
+
push("lint", lint.errors.length ? "fail" : lint.warnings.length ? "warn" : "pass", {
|
|
8948
|
+
errors: lint.errors.map((i) => ({ rule: i.rule, message: i.message })),
|
|
8949
|
+
warnings: lint.warnings.map((i) => i.rule)
|
|
8950
|
+
});
|
|
8951
|
+
return finish(rendered, links);
|
|
8952
|
+
}
|
|
8953
|
+
async function renderForContact(mailer, tpl, contact, opts) {
|
|
8954
|
+
let resolved;
|
|
8955
|
+
try {
|
|
8956
|
+
resolved = await resolveVars(mailer.config.varsAdapter, contact, {
|
|
8957
|
+
reason: opts.reason,
|
|
8958
|
+
templateSlug: tpl.slug,
|
|
8959
|
+
eventProperties: opts.eventProperties
|
|
8960
|
+
});
|
|
8961
|
+
} catch (err) {
|
|
8962
|
+
throw new Error(`varsAdapter.resolve threw: ${String(err?.message ?? err)}`);
|
|
8963
|
+
}
|
|
8964
|
+
const unsubscribeUrl = unsubscribeUrlFor(mailer, contact.email);
|
|
8965
|
+
const context = {
|
|
8966
|
+
...resolved,
|
|
8967
|
+
contact,
|
|
8968
|
+
vars: opts.vars ?? {},
|
|
8969
|
+
event: opts.eventProperties ?? {},
|
|
8970
|
+
unsubscribeUrl,
|
|
8971
|
+
senderAddress: mailer.config.senderAddress
|
|
8972
|
+
};
|
|
8973
|
+
const rendered = await renderTemplate(tpl, context, { helpers: mailer.config.handlebarsHelpers });
|
|
8974
|
+
return { rendered, resolved, unsubscribeUrl, context };
|
|
8975
|
+
}
|
|
8976
|
+
function unsubscribeUrlFor(mailer, email) {
|
|
8977
|
+
const expiresAt = new Date(Date.now() + mailer.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
|
|
8978
|
+
const token = signUnsubscribeToken({ email, scope: "marketing", expiresAt }, mailer.config.unsubscribeSecret);
|
|
8979
|
+
return `${mailer.config.publicUrl}/m/unsub/${token}`;
|
|
8980
|
+
}
|
|
8981
|
+
function extractLinks(html) {
|
|
8982
|
+
const out = [];
|
|
8983
|
+
for (const m of html.matchAll(HREF_RE)) {
|
|
8984
|
+
const href = (m[1] ?? m[2] ?? "").trim();
|
|
8985
|
+
if (!href || href.startsWith("#")) continue;
|
|
8986
|
+
out.push(href);
|
|
8987
|
+
}
|
|
8988
|
+
return out;
|
|
8989
|
+
}
|
|
8990
|
+
var BUILTIN_HELPERS = /* @__PURE__ */ new Set([
|
|
8991
|
+
"eq",
|
|
8992
|
+
"ne",
|
|
8993
|
+
"gt",
|
|
8994
|
+
"lt",
|
|
8995
|
+
"gte",
|
|
8996
|
+
"lte",
|
|
8997
|
+
"and",
|
|
8998
|
+
"or",
|
|
8999
|
+
"not",
|
|
9000
|
+
"formatDate",
|
|
9001
|
+
"formatNumber",
|
|
9002
|
+
"formatCurrency",
|
|
9003
|
+
"pluralize",
|
|
9004
|
+
"if",
|
|
9005
|
+
"unless",
|
|
9006
|
+
"each",
|
|
9007
|
+
"with",
|
|
9008
|
+
"else",
|
|
9009
|
+
"lookup",
|
|
9010
|
+
"log",
|
|
9011
|
+
"this",
|
|
9012
|
+
"true",
|
|
9013
|
+
"false",
|
|
9014
|
+
"null",
|
|
9015
|
+
"undefined"
|
|
9016
|
+
]);
|
|
9017
|
+
var MUSTACHE_RE = /\{\{\{?([^{}]*)\}\}\}?/g;
|
|
9018
|
+
var BLOCK_SCOPE_RE = /\{\{#(each|with)\b[\s\S]*?\{\{\/\1\}\}/g;
|
|
9019
|
+
var HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
9020
|
+
var PATH_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
|
|
9021
|
+
function referencedPaths(source, helperNames = []) {
|
|
9022
|
+
const helpers = /* @__PURE__ */ new Set([...BUILTIN_HELPERS, ...helperNames]);
|
|
9023
|
+
const out = /* @__PURE__ */ new Set();
|
|
9024
|
+
const scanned = source.replace(HTML_COMMENT_RE, "").replace(BLOCK_SCOPE_RE, "");
|
|
9025
|
+
for (const m of scanned.matchAll(MUSTACHE_RE)) {
|
|
9026
|
+
let expr = (m[1] ?? "").trim();
|
|
9027
|
+
if (!expr || expr.startsWith("!")) continue;
|
|
9028
|
+
expr = expr.replace(/^[#/^]\s*/, "").replace(/^else\b\s*/, "");
|
|
9029
|
+
for (let tok of expr.split(/[\s()]+/)) {
|
|
9030
|
+
if (!tok) continue;
|
|
9031
|
+
const eq = tok.indexOf("=");
|
|
9032
|
+
if (eq > 0) tok = tok.slice(eq + 1);
|
|
9033
|
+
if (/^['"]/.test(tok) || /^-?\d/.test(tok)) continue;
|
|
9034
|
+
if (tok.startsWith("@") || tok.startsWith("../") || tok.startsWith("this.")) continue;
|
|
9035
|
+
if (helpers.has(tok) || !PATH_RE.test(tok)) continue;
|
|
9036
|
+
out.add(tok);
|
|
9037
|
+
}
|
|
9038
|
+
}
|
|
9039
|
+
return [...out];
|
|
9040
|
+
}
|
|
9041
|
+
function lookupPath(ctx, path3) {
|
|
9042
|
+
let cur = ctx;
|
|
9043
|
+
for (const part of path3.split(".")) {
|
|
9044
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
9045
|
+
cur = cur[part];
|
|
9046
|
+
}
|
|
9047
|
+
return cur;
|
|
9048
|
+
}
|
|
9049
|
+
function testContactMatcher(spec) {
|
|
9050
|
+
if (!spec) return null;
|
|
9051
|
+
if (spec instanceof RegExp) return (email) => spec.test(email);
|
|
9052
|
+
if (typeof spec === "function") return (email) => !!spec(email);
|
|
9053
|
+
return null;
|
|
9054
|
+
}
|
|
9055
|
+
function bearerAuth(tokens) {
|
|
9056
|
+
const hashed = tokens.map((t) => ({ hash: crypto2.createHash("sha256").update(t.token).digest(), actor: t.actor }));
|
|
9057
|
+
return (req, res, next) => {
|
|
9058
|
+
const header = String(req.headers.authorization ?? "");
|
|
9059
|
+
const m = /^Bearer\s+(\S+)\s*$/i.exec(header);
|
|
9060
|
+
if (!m) {
|
|
9061
|
+
return res.status(401).json({ error: "unauthorized", message: "send Authorization: Bearer <token>" });
|
|
9062
|
+
}
|
|
9063
|
+
const presented = crypto2.createHash("sha256").update(m[1] ?? "").digest();
|
|
9064
|
+
const match = hashed.find((h) => crypto2.timingSafeEqual(h.hash, presented));
|
|
9065
|
+
if (!match) return res.status(401).json({ error: "unauthorized", message: "unknown token" });
|
|
9066
|
+
req.actor = match.actor;
|
|
9067
|
+
next();
|
|
9068
|
+
};
|
|
9069
|
+
}
|
|
9070
|
+
async function advanceOnce(runId, ctx, actor, c) {
|
|
9071
|
+
for (let hop = 0; hop < 10; hop += 1) {
|
|
9072
|
+
const run = await c.flowRuns.findOne({ _id: runId });
|
|
9073
|
+
if (!run || run.status !== "active") return false;
|
|
9074
|
+
if (run.nextActionAt && run.nextActionAt.getTime() > Date.now()) {
|
|
9075
|
+
await c.flowRuns.updateOne(
|
|
9076
|
+
{ _id: runId, status: "active" },
|
|
9077
|
+
{
|
|
9078
|
+
$set: { nextActionAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() },
|
|
9079
|
+
$push: {
|
|
9080
|
+
history: {
|
|
9081
|
+
stepIndex: Math.max(0, run.currentStepIndex - 1),
|
|
9082
|
+
action: "wait_completed",
|
|
9083
|
+
at: /* @__PURE__ */ new Date(),
|
|
9084
|
+
details: { forcedBy: actor, scheduledFor: run.nextActionAt }
|
|
9085
|
+
}
|
|
9086
|
+
}
|
|
9087
|
+
}
|
|
9088
|
+
);
|
|
9089
|
+
}
|
|
9090
|
+
const before = (await c.flowRuns.findOne({ _id: runId }))?.history.length ?? 0;
|
|
9091
|
+
await processOneRunStep(runId, ctx);
|
|
9092
|
+
const after = await c.flowRuns.findOne({ _id: runId });
|
|
9093
|
+
if (!after) return false;
|
|
9094
|
+
const last = after.history[after.history.length - 1];
|
|
9095
|
+
const progressed = after.history.length > before;
|
|
9096
|
+
if (progressed && last?.action === "wait_started" && after.status === "active") continue;
|
|
9097
|
+
return after.status === "active" && progressed;
|
|
9098
|
+
}
|
|
9099
|
+
return false;
|
|
9100
|
+
}
|
|
9101
|
+
var WAIT_TARGETS = /* @__PURE__ */ new Set(["sent", "delivered", "opened", "clicked", "terminal"]);
|
|
9102
|
+
function waitTargetReached(send, target) {
|
|
9103
|
+
switch (target) {
|
|
9104
|
+
case "sent":
|
|
9105
|
+
return send.status === "sent" || send.status === "delivered" || !!send.sentAt;
|
|
9106
|
+
case "delivered":
|
|
9107
|
+
return send.status === "delivered" || !!send.deliveredAt;
|
|
9108
|
+
case "opened":
|
|
9109
|
+
return !!send.openedAt;
|
|
9110
|
+
case "clicked":
|
|
9111
|
+
return !!send.firstClickAt;
|
|
9112
|
+
case "terminal":
|
|
9113
|
+
return ["delivered", "bounced", "failed", "suppressed", "cancelled", "complained"].includes(send.status);
|
|
9114
|
+
default:
|
|
9115
|
+
return false;
|
|
9116
|
+
}
|
|
9117
|
+
}
|
|
9118
|
+
function sendSummary(s) {
|
|
9119
|
+
return {
|
|
9120
|
+
id: String(s._id),
|
|
9121
|
+
templateSlug: s.templateSlug,
|
|
9122
|
+
externalId: s.externalId,
|
|
9123
|
+
email: s.emailAtSend,
|
|
9124
|
+
kind: s.kind,
|
|
9125
|
+
status: s.status,
|
|
9126
|
+
provider: s.provider,
|
|
9127
|
+
providerMessageId: s.providerMessageId,
|
|
9128
|
+
subject: s.subject,
|
|
9129
|
+
errorMessage: s.errorMessage,
|
|
9130
|
+
flowRunId: s.flowRunId ? String(s.flowRunId) : null,
|
|
9131
|
+
queuedAt: s.queuedAt,
|
|
9132
|
+
sentAt: s.sentAt,
|
|
9133
|
+
deliveredAt: s.deliveredAt,
|
|
9134
|
+
openedAt: s.openedAt,
|
|
9135
|
+
firstClickAt: s.firstClickAt,
|
|
9136
|
+
bounceType: s.bounceType
|
|
9137
|
+
};
|
|
9138
|
+
}
|
|
9139
|
+
function runSummary(r) {
|
|
9140
|
+
return {
|
|
9141
|
+
id: String(r._id),
|
|
9142
|
+
flowSlug: r.flowSlug,
|
|
9143
|
+
flowVersion: r.flowVersion,
|
|
9144
|
+
externalId: r.externalId,
|
|
9145
|
+
email: r.emailAtEntry,
|
|
9146
|
+
status: r.status,
|
|
9147
|
+
currentStepIndex: r.currentStepIndex,
|
|
9148
|
+
currentBranchPath: r.currentBranchPath,
|
|
9149
|
+
nextActionAt: r.nextActionAt,
|
|
9150
|
+
enteredAt: r.enteredAt,
|
|
9151
|
+
exitedAt: r.exitedAt,
|
|
9152
|
+
exitReason: r.exitReason,
|
|
9153
|
+
triggerEvent: r.triggerEvent ?? null,
|
|
9154
|
+
history: r.history
|
|
9155
|
+
};
|
|
9156
|
+
}
|
|
9157
|
+
function objectOrUndefined(v) {
|
|
9158
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
9159
|
+
}
|
|
9160
|
+
function sleep2(ms) {
|
|
9161
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
9162
|
+
}
|
|
9163
|
+
function wrap2(fn) {
|
|
9164
|
+
return (req, res, next) => {
|
|
9165
|
+
Promise.resolve(fn(req, res)).catch(next);
|
|
9166
|
+
};
|
|
9167
|
+
}
|
|
9168
|
+
var ENDPOINTS = [
|
|
9169
|
+
{ method: "GET", path: "/", summary: "This listing, the package version and the actor behind the token." },
|
|
9170
|
+
{ method: "*", path: "/api/*", summary: "The full admin JSON API (flows, templates, contacts, sends, health, setup-status, \u2026) with this token as the actor." },
|
|
9171
|
+
{ 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." },
|
|
9172
|
+
{ method: "POST", path: "/templates/verify-all", summary: "Verify every template (or {slugs}) for each of {contactIds}; a matrix of pass/fail." },
|
|
9173
|
+
{ 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." },
|
|
9174
|
+
{ 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 },
|
|
9175
|
+
{ 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." },
|
|
9176
|
+
{ method: "POST", path: "/sends/:id/dispatch", summary: "Dispatch a queued send now (test contacts only).", testContactsOnly: true },
|
|
9177
|
+
{ 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." },
|
|
9178
|
+
{ 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}." },
|
|
9179
|
+
{ method: "POST", path: "/flows/:slug/disarm", summary: "Disable the flow (pause). In-flight runs continue." },
|
|
9180
|
+
{ method: "POST", path: "/flows/:slug/gate", summary: "Publish a canary version whose first step exits anyone without {tag}." },
|
|
9181
|
+
{ method: "POST", path: "/flows/:slug/ungate", summary: "Restore the newest ungated version." },
|
|
9182
|
+
{ method: "GET", path: "/runs?externalId=&flowSlug=&status=&limit=", summary: "List flow runs." },
|
|
9183
|
+
{ method: "GET", path: "/runs/:id", summary: "One run with its history and sends." },
|
|
9184
|
+
{ 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 },
|
|
9185
|
+
{ method: "POST", path: "/runs/:id/cancel", summary: "Exit an active run and cancel its queued sends." },
|
|
9186
|
+
{ method: "POST", path: "/events", summary: "Fire {name} for a test contact {externalId} with {properties} and optional {dedupeKey}.", testContactsOnly: true },
|
|
9187
|
+
{ method: "GET", path: "/contacts/:externalId", summary: "Contact with subscription, suppressions, recent events, sends and runs." },
|
|
9188
|
+
{ method: "GET", path: "/contacts/by-email/:email", summary: "Same, looked up by email." },
|
|
9189
|
+
{ 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 },
|
|
9190
|
+
{ method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
|
|
9191
|
+
{ method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
|
|
9192
|
+
{ 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 },
|
|
9193
|
+
{ method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
|
|
9194
|
+
{ method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
|
|
9195
|
+
{ method: "GET", path: "/status", summary: "One document with setup checks, health, every flow (enabled, version, watermark, gate, active runs), every template, and 24h counts." }
|
|
9196
|
+
];
|
|
9197
|
+
var ALLOWED_EXTENSIONS = [".zip", ".gz", ".xml"];
|
|
9198
|
+
var DEFAULT_PATH = "/inbound/dmarc";
|
|
9199
|
+
var DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
9200
|
+
var DEFAULT_MAX_FILES = 10;
|
|
9201
|
+
function mountDmarcInbound(router, mailer, opts, logger) {
|
|
9202
|
+
const secret = opts?.secret;
|
|
9203
|
+
if (!opts || typeof secret !== "string" || secret.length === 0) return null;
|
|
9204
|
+
const routePath = opts.path ?? DEFAULT_PATH;
|
|
9205
|
+
const parseInbound = opts.parseInbound ?? sendgridInboundParser;
|
|
9206
|
+
const allowedDomains = normalizeDomains(opts.allowedDomains ?? deriveSenderDomains(mailer));
|
|
9207
|
+
if (allowedDomains.size === 0) {
|
|
9208
|
+
logger.warn?.(
|
|
9209
|
+
{ path: routePath },
|
|
9210
|
+
"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"
|
|
9211
|
+
);
|
|
9212
|
+
}
|
|
9213
|
+
const upload = multer({
|
|
9214
|
+
storage: multer.memoryStorage(),
|
|
9215
|
+
limits: {
|
|
9216
|
+
fileSize: opts.maxFileSizeBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
9217
|
+
files: opts.maxFiles ?? DEFAULT_MAX_FILES,
|
|
9218
|
+
// Inbound Parse sends the whole email as form fields (headers, text,
|
|
9219
|
+
// html, ...). We read none of them, but they still have to be bounded.
|
|
9220
|
+
fields: 40,
|
|
9221
|
+
fieldSize: 1024 * 1024
|
|
9222
|
+
}
|
|
9223
|
+
});
|
|
9224
|
+
router.post(
|
|
9225
|
+
routePath,
|
|
9226
|
+
wrap(logger, (req, res, next) => {
|
|
9227
|
+
if (!isAuthorized(req, secret)) {
|
|
9228
|
+
logger.warn?.(
|
|
9229
|
+
{ path: routePath, ip: req.ip },
|
|
9230
|
+
"mailery: DMARC inbound request rejected \u2014 bad or missing shared secret"
|
|
9231
|
+
);
|
|
9232
|
+
res.status(401).json({ error: "unauthorized" });
|
|
9233
|
+
return;
|
|
9234
|
+
}
|
|
9235
|
+
next();
|
|
9236
|
+
}),
|
|
9237
|
+
(req, res, next) => {
|
|
9238
|
+
upload.any()(req, res, (err) => {
|
|
9239
|
+
if (!err) return next();
|
|
9240
|
+
const code = err?.code;
|
|
9241
|
+
const status = code === "LIMIT_FILE_SIZE" || code === "LIMIT_FILE_COUNT" ? 413 : 400;
|
|
9242
|
+
logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound upload rejected");
|
|
9243
|
+
res.status(status).json({ error: "upload_rejected", message: String(code ?? "malformed_multipart") });
|
|
9244
|
+
});
|
|
9245
|
+
},
|
|
9246
|
+
wrap(logger, async (req, res) => {
|
|
9247
|
+
let attachments;
|
|
9248
|
+
try {
|
|
9249
|
+
attachments = parseInbound(req);
|
|
9250
|
+
} catch (err) {
|
|
9251
|
+
logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound payload unreadable");
|
|
9252
|
+
return res.status(400).json({ error: "bad_payload" });
|
|
9253
|
+
}
|
|
9254
|
+
const candidates = attachments.filter((a) => hasAllowedExtension(a.filename));
|
|
9255
|
+
if (candidates.length === 0) {
|
|
9256
|
+
logger.info?.(
|
|
9257
|
+
{ path: routePath, attachments: attachments.length },
|
|
9258
|
+
"mailery: DMARC inbound message carried no report attachment \u2014 ignored"
|
|
9259
|
+
);
|
|
9260
|
+
return res.status(200).json({ ok: true, ingested: 0, ignored: attachments.length });
|
|
9261
|
+
}
|
|
9262
|
+
const ctx = mailer.getRunnerContext();
|
|
9263
|
+
const allowDomain = allowedDomains.size === 0 ? void 0 : (d) => allowedDomains.has(d.toLowerCase());
|
|
9264
|
+
const ingested = [];
|
|
9265
|
+
const rejected = [];
|
|
9266
|
+
for (const file of candidates) {
|
|
9267
|
+
try {
|
|
9268
|
+
const result = await ingestDmarcAttachment(ctx, file.buffer, file.filename, { allowDomain });
|
|
9269
|
+
ingested.push({ reportId: result.reportId, domain: result.domain, duplicate: result.duplicate });
|
|
9270
|
+
} catch (err) {
|
|
9271
|
+
const message = String(err?.message ?? err);
|
|
9272
|
+
rejected.push({ filename: file.filename, message });
|
|
9273
|
+
logger.warn?.(
|
|
9274
|
+
{ path: routePath, filename: file.filename, err },
|
|
9275
|
+
"mailery: DMARC inbound attachment rejected"
|
|
9276
|
+
);
|
|
9277
|
+
}
|
|
9278
|
+
}
|
|
9279
|
+
if (ingested.length === 0) {
|
|
9280
|
+
return res.status(400).json({ error: "ingest_failed", rejected });
|
|
9281
|
+
}
|
|
9282
|
+
return res.status(200).json({ ok: true, ingested, rejected });
|
|
9283
|
+
})
|
|
9284
|
+
);
|
|
9285
|
+
return routePath;
|
|
9286
|
+
}
|
|
9287
|
+
function isAuthorized(req, secret) {
|
|
9288
|
+
const presented = extractPresentedSecret(req);
|
|
9289
|
+
if (presented === null) return false;
|
|
9290
|
+
return constantTimeEquals(presented, secret);
|
|
9291
|
+
}
|
|
9292
|
+
function extractPresentedSecret(req) {
|
|
9293
|
+
const header = req.headers.authorization;
|
|
9294
|
+
if (typeof header !== "string") return null;
|
|
9295
|
+
const [scheme, ...rest] = header.split(" ");
|
|
9296
|
+
const value = rest.join(" ").trim();
|
|
9297
|
+
if (!scheme || value === "") return null;
|
|
9298
|
+
switch (scheme.toLowerCase()) {
|
|
9299
|
+
case "basic": {
|
|
9300
|
+
let decoded;
|
|
9301
|
+
try {
|
|
9302
|
+
decoded = Buffer.from(value, "base64").toString("utf8");
|
|
9303
|
+
} catch {
|
|
9304
|
+
return null;
|
|
9305
|
+
}
|
|
9306
|
+
const sep = decoded.indexOf(":");
|
|
9307
|
+
return sep === -1 ? null : decoded.slice(sep + 1);
|
|
9308
|
+
}
|
|
9309
|
+
case "bearer":
|
|
9310
|
+
return value;
|
|
9311
|
+
default:
|
|
9312
|
+
return null;
|
|
9313
|
+
}
|
|
9314
|
+
}
|
|
9315
|
+
function constantTimeEquals(a, b) {
|
|
9316
|
+
const ha = createHash("sha256").update(a, "utf8").digest();
|
|
9317
|
+
const hb = createHash("sha256").update(b, "utf8").digest();
|
|
9318
|
+
return timingSafeEqual(ha, hb);
|
|
9319
|
+
}
|
|
9320
|
+
var sendgridInboundParser = (req) => {
|
|
9321
|
+
const files = req.files;
|
|
9322
|
+
if (!Array.isArray(files)) return [];
|
|
9323
|
+
return files.filter((f) => Boolean(f) && Buffer.isBuffer(f.buffer)).map((f) => ({ filename: safeFilename(f.originalname), buffer: f.buffer }));
|
|
9324
|
+
};
|
|
9325
|
+
function safeFilename(raw) {
|
|
9326
|
+
if (typeof raw !== "string") return "";
|
|
9327
|
+
return raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/[\\/]/g, "_").slice(0, 255);
|
|
9328
|
+
}
|
|
9329
|
+
function hasAllowedExtension(filename) {
|
|
9330
|
+
const lower = filename.toLowerCase();
|
|
9331
|
+
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
9332
|
+
}
|
|
9333
|
+
function normalizeDomains(domains) {
|
|
9334
|
+
const out = /* @__PURE__ */ new Set();
|
|
9335
|
+
for (const d of domains) {
|
|
9336
|
+
if (typeof d !== "string") continue;
|
|
9337
|
+
const trimmed = d.trim().toLowerCase().replace(/^\.+/, "");
|
|
9338
|
+
if (trimmed) out.add(trimmed);
|
|
9339
|
+
}
|
|
9340
|
+
return out;
|
|
9341
|
+
}
|
|
9342
|
+
function deriveSenderDomains(mailer) {
|
|
9343
|
+
const out = [];
|
|
9344
|
+
const push = (domain) => {
|
|
9345
|
+
if (!domain) return;
|
|
9346
|
+
out.push(domain);
|
|
9347
|
+
const labels = domain.split(".");
|
|
9348
|
+
if (labels.length > 2) out.push(labels.slice(-2).join("."));
|
|
9349
|
+
};
|
|
9350
|
+
for (const domain of Object.keys(mailer.config.senderDomains ?? {})) push(domain.toLowerCase());
|
|
9351
|
+
push(emailDomain(mailer.config.fromDefaults?.email));
|
|
9352
|
+
push(emailDomain(mailer.config.transactionalFromDefaults?.email));
|
|
9353
|
+
push(emailDomain(mailer.config.senderAddress));
|
|
9354
|
+
return out;
|
|
9355
|
+
}
|
|
9356
|
+
function emailDomain(email) {
|
|
9357
|
+
if (typeof email !== "string") return void 0;
|
|
9358
|
+
const at = email.lastIndexOf("@");
|
|
9359
|
+
if (at === -1) return void 0;
|
|
9360
|
+
const domain = email.slice(at + 1).trim().toLowerCase();
|
|
9361
|
+
return domain || void 0;
|
|
9362
|
+
}
|
|
9363
|
+
|
|
9364
|
+
// src/server/api/public.ts
|
|
9365
|
+
var PIXEL = Buffer.from(
|
|
9366
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
9367
|
+
"base64"
|
|
9368
|
+
);
|
|
9369
|
+
function createPublicRouter(mailer, opts = {}) {
|
|
9370
|
+
const router = Router();
|
|
9371
|
+
const logger = opts.logger ?? consoleRouteLogger;
|
|
9372
|
+
const pendingUnsubsPath = opts.pendingUnsubsPath ?? mailer.config.pendingUnsubsPath ?? null;
|
|
9373
|
+
if (opts.pendingUnsubsPath && !mailer.config.pendingUnsubsPath) {
|
|
9374
|
+
logger.warn?.(
|
|
9375
|
+
{ path: opts.pendingUnsubsPath },
|
|
9376
|
+
"mailery: pendingUnsubsPath set on the public router but not in MailerConfig \u2014 the tick drain will not replay this journal"
|
|
9377
|
+
);
|
|
9378
|
+
}
|
|
9379
|
+
if (!pendingUnsubsPath) {
|
|
9380
|
+
logger.warn?.(
|
|
9381
|
+
{},
|
|
9382
|
+
"mailery: no pendingUnsubsPath configured \u2014 POST /unsub answers 503 when Mongo is unreachable instead of journaling the opt-out"
|
|
9383
|
+
);
|
|
9384
|
+
}
|
|
9385
|
+
const dmarcInboundPath = mountDmarcInbound(router, mailer, opts.dmarcInbound, logger);
|
|
9386
|
+
if (dmarcInboundPath) {
|
|
9387
|
+
logger.info?.(
|
|
9388
|
+
{ path: dmarcInboundPath },
|
|
9389
|
+
"mailery: DMARC inbound route mounted (shared-secret auth; SendGrid Inbound Parse does not sign payloads)"
|
|
9390
|
+
);
|
|
9391
|
+
}
|
|
9392
|
+
router.use(
|
|
9393
|
+
"/webhooks",
|
|
9394
|
+
express.json({
|
|
9395
|
+
limit: "5mb",
|
|
9396
|
+
verify: (req, _res, buf) => {
|
|
9397
|
+
req.rawBody = buf;
|
|
9398
|
+
}
|
|
9399
|
+
})
|
|
9400
|
+
);
|
|
9401
|
+
router.use("/unsub", express.urlencoded({ extended: false }));
|
|
9402
|
+
router.get("/open/:sendId.png", wrap(logger, async (req, res) => {
|
|
9403
|
+
res.setHeader("Content-Type", "image/png");
|
|
9404
|
+
res.setHeader("Content-Length", String(PIXEL.length));
|
|
9405
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
9406
|
+
res.status(200).end(PIXEL);
|
|
9407
|
+
const parsed = splitOpenParam(req.params.sendId);
|
|
9408
|
+
if (!parsed) return;
|
|
9409
|
+
const { id, sig } = parsed;
|
|
9410
|
+
if (!ObjectId.isValid(id)) return;
|
|
9411
|
+
const sendId = new ObjectId(id);
|
|
9412
|
+
const verdict = checkTrackingSignature({
|
|
9413
|
+
scope: "open",
|
|
9414
|
+
params: { sendId: id },
|
|
9415
|
+
sig,
|
|
9416
|
+
secret: mailer.config.unsubscribeSecret,
|
|
9417
|
+
requireSigned: mailer.config.requireSignedTrackingUrls,
|
|
9418
|
+
logger,
|
|
9419
|
+
fields: { sendId: id }
|
|
9420
|
+
});
|
|
9421
|
+
if (verdict === "rejected") return;
|
|
9422
|
+
try {
|
|
9423
|
+
const send = await mailer.collections.sends.findOne(
|
|
9424
|
+
{ _id: sendId },
|
|
9425
|
+
{ projection: { openedAt: 1, queuedAt: 1 } }
|
|
9426
|
+
);
|
|
9427
|
+
if (!send) return;
|
|
9428
|
+
if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
|
|
9429
|
+
logger.warn?.({ sendId: id }, "mailery: open ignored \u2014 tracking URL past trackingUrlLifetimeDays");
|
|
9430
|
+
return;
|
|
9431
|
+
}
|
|
9432
|
+
const now = /* @__PURE__ */ new Date();
|
|
9433
|
+
await mailer.collections.sends.updateOne(
|
|
9434
|
+
{ _id: sendId },
|
|
9435
|
+
{
|
|
9436
|
+
$set: {
|
|
9437
|
+
openedAt: send.openedAt ?? now,
|
|
9438
|
+
status: "delivered"
|
|
9439
|
+
},
|
|
9440
|
+
$inc: { openCount: 1 },
|
|
9441
|
+
// Per-open user agent, so `hasOpenedExcludingBots` has a signal to
|
|
9442
|
+
// filter on. Capped: an open pixel can be re-fetched indefinitely
|
|
9443
|
+
// (every time the recipient re-opens the mail) and an unbounded
|
|
9444
|
+
// array would eventually run the send document into the 16MB limit.
|
|
9445
|
+
$push: {
|
|
9446
|
+
opens: {
|
|
9447
|
+
$each: [{ openedAt: now, userAgent: requestUserAgent(req) }],
|
|
9448
|
+
$slice: -MAX_TRACKED_OPENS
|
|
9449
|
+
}
|
|
9450
|
+
}
|
|
9451
|
+
}
|
|
9452
|
+
);
|
|
9453
|
+
} catch (err) {
|
|
9454
|
+
logger.error?.({ err, sendId: id }, "mailery: open pixel update failed");
|
|
9455
|
+
}
|
|
9456
|
+
}));
|
|
9457
|
+
router.get("/click/:sendId/:linkId{/:sig}", wrap(logger, async (req, res) => {
|
|
9458
|
+
const { sendId: sendIdStr, linkId, sig } = req.params;
|
|
9459
|
+
if (!ObjectId.isValid(sendIdStr)) return res.status(400).end();
|
|
9460
|
+
const sendId = new ObjectId(sendIdStr);
|
|
9461
|
+
const verdict = checkTrackingSignature({
|
|
9462
|
+
scope: "click",
|
|
9463
|
+
params: { sendId: sendIdStr, linkId },
|
|
9464
|
+
sig,
|
|
9465
|
+
secret: mailer.config.unsubscribeSecret,
|
|
9466
|
+
requireSigned: mailer.config.requireSignedTrackingUrls,
|
|
9467
|
+
logger,
|
|
9468
|
+
fields: { sendId: sendIdStr, linkId }
|
|
9469
|
+
});
|
|
9470
|
+
if (verdict === "rejected") return res.status(404).end();
|
|
9471
|
+
const send = await mailer.collections.sends.findOne(
|
|
9472
|
+
{ _id: sendId },
|
|
9473
|
+
{ projection: { links: 1, firstClickAt: 1, queuedAt: 1 } }
|
|
9474
|
+
);
|
|
9475
|
+
if (!send) return res.status(404).end();
|
|
9476
|
+
if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
|
|
9477
|
+
logger.warn?.(
|
|
9478
|
+
{ sendId: sendIdStr, linkId },
|
|
9479
|
+
"mailery: click rejected \u2014 tracking URL past trackingUrlLifetimeDays"
|
|
9480
|
+
);
|
|
9481
|
+
return res.status(404).end();
|
|
9482
|
+
}
|
|
9483
|
+
const link = (send.links ?? []).find((l) => l.linkId === linkId);
|
|
9484
|
+
if (!link) return res.status(404).end();
|
|
9485
|
+
if (!isSafeRedirectTarget(link.url)) {
|
|
9486
|
+
logger.warn?.(
|
|
9487
|
+
{ sendId: sendIdStr, linkId, url: link.url },
|
|
9488
|
+
"mailery: click redirect blocked \u2014 disallowed URL scheme"
|
|
9489
|
+
);
|
|
9490
|
+
return res.status(400).type("html").send("<!doctype html><html><body><p>This link cannot be opened.</p></body></html>");
|
|
9491
|
+
}
|
|
9492
|
+
res.redirect(302, link.url);
|
|
9493
|
+
try {
|
|
9494
|
+
await mailer.collections.sends.updateOne(
|
|
9495
|
+
{ _id: sendId },
|
|
9496
|
+
{
|
|
9497
|
+
$set: { firstClickAt: send.firstClickAt ?? /* @__PURE__ */ new Date() },
|
|
9498
|
+
$inc: { clickCount: 1 },
|
|
9499
|
+
$push: {
|
|
9500
|
+
clickedLinks: {
|
|
9501
|
+
url: link.url,
|
|
9502
|
+
linkId,
|
|
9503
|
+
clickedAt: /* @__PURE__ */ new Date(),
|
|
9504
|
+
// The bot filter has always read `clickedLinks[].userAgent`; until
|
|
9505
|
+
// now nothing ever wrote it, so every click scored as human.
|
|
9506
|
+
userAgent: requestUserAgent(req)
|
|
9507
|
+
}
|
|
9508
|
+
}
|
|
9509
|
+
}
|
|
9510
|
+
);
|
|
9511
|
+
} catch (err) {
|
|
9512
|
+
logger.error?.({ err, sendId: sendIdStr, linkId }, "mailery: click recording failed");
|
|
9513
|
+
}
|
|
9514
|
+
}));
|
|
9515
|
+
router.get("/unsub/:token", wrap(logger, (req, res) => {
|
|
9516
|
+
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7480
9517
|
if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
|
|
7481
9518
|
res.status(200).type("html").send(`<!doctype html>
|
|
7482
9519
|
<html><head>
|
|
@@ -7490,32 +9527,55 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7490
9527
|
<button type="submit">Unsubscribe</button>
|
|
7491
9528
|
</form>
|
|
7492
9529
|
</body></html>`);
|
|
7493
|
-
});
|
|
7494
|
-
router.post("/unsub/:token", async (req, res) => {
|
|
9530
|
+
}));
|
|
9531
|
+
router.post("/unsub/:token", wrap(logger, async (req, res) => {
|
|
7495
9532
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7496
9533
|
if (!decoded) {
|
|
7497
9534
|
res.status(200).end();
|
|
7498
9535
|
return;
|
|
7499
9536
|
}
|
|
7500
|
-
|
|
9537
|
+
const write = mailer.unsubscribe(decoded.email, {
|
|
9538
|
+
scope: decoded.scope,
|
|
9539
|
+
reason: "user_request",
|
|
9540
|
+
source: "one-click"
|
|
9541
|
+
});
|
|
9542
|
+
let dbError = null;
|
|
7501
9543
|
try {
|
|
7502
|
-
await mailer.
|
|
7503
|
-
scope: decoded.scope,
|
|
7504
|
-
reason: "user_request",
|
|
7505
|
-
source: "one-click"
|
|
7506
|
-
});
|
|
9544
|
+
await withTimeout(write, mailer.config.unsubscribeWriteTimeoutMs);
|
|
7507
9545
|
} catch (err) {
|
|
9546
|
+
dbError = err ?? new Error("unsubscribe write failed");
|
|
9547
|
+
void write.catch(() => {
|
|
9548
|
+
});
|
|
9549
|
+
}
|
|
9550
|
+
if (dbError) {
|
|
9551
|
+
if (!pendingUnsubsPath) {
|
|
9552
|
+
logger.error?.(
|
|
9553
|
+
{ err: dbError },
|
|
9554
|
+
"mailery: unsubscribe write failed and no pendingUnsubsPath is configured \u2014 answering 503"
|
|
9555
|
+
);
|
|
9556
|
+
return sendUnsubUnavailable(res);
|
|
9557
|
+
}
|
|
7508
9558
|
try {
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
9559
|
+
appendPendingUnsub(pendingUnsubsPath, {
|
|
9560
|
+
email: decoded.email,
|
|
9561
|
+
scope: decoded.scope,
|
|
9562
|
+
at: Date.now()
|
|
9563
|
+
});
|
|
9564
|
+
logger.warn?.(
|
|
9565
|
+
{ err: dbError, path: pendingUnsubsPath },
|
|
9566
|
+
"mailery: unsubscribe journaled to disk \u2014 will be replayed by the tick drain"
|
|
7512
9567
|
);
|
|
7513
9568
|
} catch (diskErr) {
|
|
7514
|
-
|
|
9569
|
+
logger.error?.(
|
|
9570
|
+
{ err: dbError, diskErr, path: pendingUnsubsPath },
|
|
9571
|
+
"mailery: unsub disk fallback failed \u2014 answering 503"
|
|
9572
|
+
);
|
|
9573
|
+
return sendUnsubUnavailable(res);
|
|
7515
9574
|
}
|
|
7516
9575
|
}
|
|
7517
|
-
|
|
7518
|
-
|
|
9576
|
+
res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
|
|
9577
|
+
}));
|
|
9578
|
+
router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
|
|
7519
9579
|
const token = req.params.token;
|
|
7520
9580
|
const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
|
|
7521
9581
|
if (!decoded) {
|
|
@@ -7543,10 +9603,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7543
9603
|
} catch {
|
|
7544
9604
|
}
|
|
7545
9605
|
return res.status(200).type("html").send("<!doctype html><html><body><p>Thanks \u2014 you're subscribed.</p></body></html>");
|
|
7546
|
-
});
|
|
7547
|
-
router.post("/webhooks/:provider", async (req, res) => {
|
|
9606
|
+
}));
|
|
9607
|
+
router.post("/webhooks/:provider", wrap(logger, async (req, res) => {
|
|
7548
9608
|
const providerName = req.params.provider;
|
|
7549
|
-
const provider = mailer.providers
|
|
9609
|
+
const provider = resolveProvider(mailer.providers, providerName);
|
|
7550
9610
|
if (!provider) return res.status(404).end();
|
|
7551
9611
|
const rawBody = req.rawBody;
|
|
7552
9612
|
if (!rawBody) return res.status(400).end();
|
|
@@ -7577,7 +9637,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7577
9637
|
{ upsert: true }
|
|
7578
9638
|
);
|
|
7579
9639
|
} catch (err) {
|
|
7580
|
-
|
|
9640
|
+
logger.error?.(
|
|
9641
|
+
{ err, provider: providerName, providerEventId: evt.providerEventId },
|
|
9642
|
+
"mailery: webhook dedupe insert failed"
|
|
9643
|
+
);
|
|
7581
9644
|
}
|
|
7582
9645
|
}
|
|
7583
9646
|
if (events.length > 0) {
|
|
@@ -7586,15 +9649,94 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7586
9649
|
} catch {
|
|
7587
9650
|
}
|
|
7588
9651
|
}
|
|
7589
|
-
});
|
|
9652
|
+
}));
|
|
7590
9653
|
return router;
|
|
7591
9654
|
}
|
|
9655
|
+
var MAX_TRACKED_OPENS = 50;
|
|
9656
|
+
var MAX_UA_LENGTH = 256;
|
|
9657
|
+
function requestUserAgent(req) {
|
|
9658
|
+
const ua = req.headers["user-agent"];
|
|
9659
|
+
if (typeof ua !== "string" || ua.trim() === "") return null;
|
|
9660
|
+
return ua.slice(0, MAX_UA_LENGTH);
|
|
9661
|
+
}
|
|
9662
|
+
function splitOpenParam(raw) {
|
|
9663
|
+
if (typeof raw !== "string") return null;
|
|
9664
|
+
const parts = raw.split(".");
|
|
9665
|
+
if (parts.length === 1) return { id: parts[0] };
|
|
9666
|
+
if (parts.length === 2) return { id: parts[0], sig: parts[1] };
|
|
9667
|
+
return null;
|
|
9668
|
+
}
|
|
9669
|
+
function checkTrackingSignature(args) {
|
|
9670
|
+
const { sig, logger, fields, scope } = args;
|
|
9671
|
+
if (sig === void 0 || sig === "") {
|
|
9672
|
+
if (args.requireSigned) {
|
|
9673
|
+
logger.warn?.(
|
|
9674
|
+
{ ...fields, scope },
|
|
9675
|
+
"mailery: unsigned tracking URL rejected (requireSignedTrackingUrls)"
|
|
9676
|
+
);
|
|
9677
|
+
return "rejected";
|
|
9678
|
+
}
|
|
9679
|
+
logger.info?.(
|
|
9680
|
+
{ ...fields, scope },
|
|
9681
|
+
"mailery: unsigned tracking URL accepted \u2014 legacy grace mode"
|
|
9682
|
+
);
|
|
9683
|
+
return "legacy";
|
|
9684
|
+
}
|
|
9685
|
+
if (!verifyTrackingToken(sig, scope, args.params, args.secret)) {
|
|
9686
|
+
logger.warn?.({ ...fields, scope }, "mailery: tracking URL signature invalid");
|
|
9687
|
+
return "rejected";
|
|
9688
|
+
}
|
|
9689
|
+
return "signed";
|
|
9690
|
+
}
|
|
9691
|
+
function isTrackingExpired(send, lifetimeDays, now = /* @__PURE__ */ new Date()) {
|
|
9692
|
+
if (!lifetimeDays || lifetimeDays <= 0) return false;
|
|
9693
|
+
const queuedAt = send.queuedAt;
|
|
9694
|
+
if (!(queuedAt instanceof Date) || Number.isNaN(queuedAt.getTime())) return false;
|
|
9695
|
+
return now.getTime() - queuedAt.getTime() > lifetimeDays * 24 * 60 * 60 * 1e3;
|
|
9696
|
+
}
|
|
7592
9697
|
function sendUnsubError(res, msg) {
|
|
7593
9698
|
return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
|
|
7594
9699
|
}
|
|
9700
|
+
function sendUnsubUnavailable(res) {
|
|
9701
|
+
return res.status(503).set("Retry-After", "60").type("html").send(
|
|
9702
|
+
"<!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>"
|
|
9703
|
+
);
|
|
9704
|
+
}
|
|
9705
|
+
function withTimeout(p, ms) {
|
|
9706
|
+
if (!Number.isFinite(ms) || ms <= 0) return p;
|
|
9707
|
+
return new Promise((resolve, reject) => {
|
|
9708
|
+
const timer = setTimeout(() => {
|
|
9709
|
+
reject(new Error(`timed out after ${ms}ms`));
|
|
9710
|
+
}, ms);
|
|
9711
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
9712
|
+
p.then(
|
|
9713
|
+
(v) => {
|
|
9714
|
+
clearTimeout(timer);
|
|
9715
|
+
resolve(v);
|
|
9716
|
+
},
|
|
9717
|
+
(err) => {
|
|
9718
|
+
clearTimeout(timer);
|
|
9719
|
+
reject(err);
|
|
9720
|
+
}
|
|
9721
|
+
);
|
|
9722
|
+
});
|
|
9723
|
+
}
|
|
7595
9724
|
function escapeHtml(s) {
|
|
7596
9725
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7597
9726
|
}
|
|
9727
|
+
var ALLOWED_REDIRECT_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
9728
|
+
function isSafeRedirectTarget(raw) {
|
|
9729
|
+
if (typeof raw !== "string") return false;
|
|
9730
|
+
const trimmed = raw.trim();
|
|
9731
|
+
if (trimmed === "") return false;
|
|
9732
|
+
let parsed;
|
|
9733
|
+
try {
|
|
9734
|
+
parsed = new URL(trimmed);
|
|
9735
|
+
} catch {
|
|
9736
|
+
return false;
|
|
9737
|
+
}
|
|
9738
|
+
return ALLOWED_REDIRECT_PROTOCOLS.has(parsed.protocol);
|
|
9739
|
+
}
|
|
7598
9740
|
function lowercaseHeaders(h) {
|
|
7599
9741
|
const out = {};
|
|
7600
9742
|
for (const k of Object.keys(h)) {
|
|
@@ -7763,8 +9905,8 @@ var DEDUPE_POLICIES = [
|
|
|
7763
9905
|
];
|
|
7764
9906
|
|
|
7765
9907
|
// src/server/index.ts
|
|
7766
|
-
var
|
|
9908
|
+
var VERSION2 = "0.16.0" ;
|
|
7767
9909
|
|
|
7768
|
-
export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, varsJsonSchema, verifyUnsubscribeToken };
|
|
9910
|
+
export { DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, FLOW_STEP_KINDS, FlowOperationError, MIN_AGENT_TOKEN_LENGTH, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, TRACKING_SIG_LENGTH, VERSION2 as VERSION, applyTracking, applyWebhookEvent, armFlow, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminApiRouter, createAdminRouter, createAgentRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, disarmFlow, dispatchSend, drainPendingUnsubscribes, ensureIndexes, gateFlow, getCollections, isCanaryGate, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, referencedPaths, renderForContact, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, simulateFlow, stampWatermarkIfNull, sweepStrandedFlowRuns, ungateFlow, validateSenderDomain, varsJsonSchema, verifyTemplate, verifyTrackingToken, verifyUnsubscribeToken };
|
|
7769
9911
|
//# sourceMappingURL=index.js.map
|
|
7770
9912
|
//# sourceMappingURL=index.js.map
|