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/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 __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
21
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
22
- }) : x)(function(x) {
23
- if (typeof require !== "undefined") return require.apply(this, arguments);
24
- throw Error('Dynamic require of "' + x + '" is not supported');
25
- });
26
- var __esm = (fn, res) => function __init() {
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
- return verifier.verify(this.opts.webhookVerificationKey, sig, "base64");
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 BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
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
- if (kind === "opened") {
1801
- n++;
1802
- continue;
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 newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}`;
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 pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
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[send.provider] ?? ctx.providers[ctx.config.defaultProvider];
2376
+ const provider = resolveProvider(ctx.providers, send.provider);
2237
2377
  if (!provider) {
2238
- await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
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[config.defaultProvider]) {
4113
- throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
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
- const normalized = parsed.email;
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 true;
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", apiRouter(mailer, opts));
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 apiRouter(mailer, opts = {}) {
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
- await c.flows.updateOne({ _id: before._id }, { $set: { enabled: true, updatedAt: /* @__PURE__ */ new Date() } });
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 { checkId, emailAddress } = await client.provisionCheck();
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(500).json({ error: "provider_unknown", message: `default provider ${mailer.config.defaultProvider} is not registered` });
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[tpl.providerOverride ?? mailer.config.defaultProvider];
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
- var PIXEL = Buffer.from(
7406
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
7407
- "base64"
7408
- );
7409
- function createPublicRouter(mailer, opts = {}) {
7410
- const router = Router();
7411
- const pendingUnsubsPath = opts.pendingUnsubsPath ?? "/tmp/mailery-pending-unsubs.jsonl";
7412
- router.use(
7413
- "/webhooks",
7414
- express.json({
7415
- limit: "5mb",
7416
- verify: (req, _res, buf) => {
7417
- req.rawBody = buf;
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
- const send = await mailer.collections.sends.findOne({ _id: sendId }, { projection: { openedAt: 1 } });
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
- console.error("mailery: open pixel update failed", err);
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
- router.get("/click/:sendId/:linkId", async (req, res) => {
7448
- const { sendId: sendIdStr, linkId } = req.params;
7449
- if (!ObjectId.isValid(sendIdStr)) return res.status(400).end();
7450
- const sendId = new ObjectId(sendIdStr);
7451
- const send = await mailer.collections.sends.findOne(
7452
- { _id: sendId },
7453
- { projection: { links: 1, firstClickAt: 1 } }
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
- if (!send) return res.status(404).end();
7456
- const link = (send.links ?? []).find((l) => l.linkId === linkId);
7457
- if (!link) return res.status(404).end();
7458
- res.redirect(302, link.url);
7459
- try {
7460
- await mailer.collections.sends.updateOne(
7461
- { _id: sendId },
7462
- {
7463
- $set: { firstClickAt: send.firstClickAt ?? /* @__PURE__ */ new Date() },
7464
- $inc: { clickCount: 1 },
7465
- $push: {
7466
- clickedLinks: {
7467
- url: link.url,
7468
- linkId,
7469
- clickedAt: /* @__PURE__ */ new Date()
7470
- }
7471
- }
7472
- }
7473
- );
7474
- } catch (err) {
7475
- console.error("mailery: click recording failed", err);
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
- router.get("/unsub/:token", (req, res) => {
7479
- const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
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
- res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
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.unsubscribe(decoded.email, {
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
- fs.appendFileSync(
7510
- pendingUnsubsPath,
7511
- JSON.stringify({ email: decoded.email, scope: decoded.scope, at: Date.now() }) + "\n"
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
- console.error("mailery: unsub disk fallback failed", { err, diskErr });
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
- router.get("/confirm-doi/:token", async (req, res) => {
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[providerName];
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
- console.error("mailery: webhook dedupe insert failed", err);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 VERSION = "0.14.0" ;
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