mailery 0.14.0 → 0.15.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.cjs CHANGED
@@ -11,11 +11,12 @@ var mjml2html = require('mjml');
11
11
  var dns = require('dns/promises');
12
12
  var net = require('net');
13
13
  var psl = require('psl');
14
+ var fastXmlParser = require('fast-xml-parser');
15
+ var fs = require('fs');
16
+ var path = require('path');
14
17
  var express = require('express');
15
18
  var multer = require('multer');
16
- var path = require('path');
17
19
  var url = require('url');
18
- var fs = require('fs');
19
20
 
20
21
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
21
22
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -28,21 +29,20 @@ var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
28
29
  var dns__default = /*#__PURE__*/_interopDefault(dns);
29
30
  var net__default = /*#__PURE__*/_interopDefault(net);
30
31
  var psl__default = /*#__PURE__*/_interopDefault(psl);
32
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
33
+ var path__default = /*#__PURE__*/_interopDefault(path);
31
34
  var express__default = /*#__PURE__*/_interopDefault(express);
32
35
  var multer__default = /*#__PURE__*/_interopDefault(multer);
33
- var path__default = /*#__PURE__*/_interopDefault(path);
34
- var fs__default = /*#__PURE__*/_interopDefault(fs);
35
36
 
36
37
  var __defProp = Object.defineProperty;
37
38
  var __getOwnPropNames = Object.getOwnPropertyNames;
38
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
39
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
40
- }) : x)(function(x) {
41
- if (typeof require !== "undefined") return require.apply(this, arguments);
42
- throw Error('Dynamic require of "' + x + '" is not supported');
43
- });
44
- var __esm = (fn, res) => function __init() {
45
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
39
+ var __esm = (fn, res, err) => function __init() {
40
+ if (err) throw err[0];
41
+ try {
42
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
43
+ } catch (e) {
44
+ throw err = [e], e;
45
+ }
46
46
  };
47
47
  var __export = (target, all) => {
48
48
  for (var name in all)
@@ -258,6 +258,31 @@ var init_mongo = __esm({
258
258
  }
259
259
  });
260
260
 
261
+ // src/server/providers/webhook-tolerance.ts
262
+ function resolveWebhookToleranceSeconds(option) {
263
+ if (option === false || option === 0) return 0;
264
+ if (option === void 0 || option === null) return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
265
+ if (typeof option !== "number" || !Number.isFinite(option) || option < 0) {
266
+ return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
267
+ }
268
+ return option;
269
+ }
270
+ function isWebhookTimestampFresh(rawTimestamp, toleranceSeconds, now = /* @__PURE__ */ new Date()) {
271
+ if (toleranceSeconds === 0) return true;
272
+ if (!rawTimestamp) return false;
273
+ if (!/^\d{1,15}$/.test(rawTimestamp)) return false;
274
+ const timestampMs = Number(rawTimestamp) * 1e3;
275
+ if (!Number.isFinite(timestampMs)) return false;
276
+ const deltaMs = Math.abs(now.getTime() - timestampMs);
277
+ return deltaMs <= toleranceSeconds * 1e3;
278
+ }
279
+ var DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
280
+ var init_webhook_tolerance = __esm({
281
+ "src/server/providers/webhook-tolerance.ts"() {
282
+ DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
283
+ }
284
+ });
285
+
261
286
  // src/server/providers/sendgrid.ts
262
287
  var sendgrid_exports = {};
263
288
  __export(sendgrid_exports, {
@@ -304,6 +329,7 @@ function mapEventType(sgEvent) {
304
329
  var SG_SIG_HEADER, SG_TS_HEADER; exports.SendGridProvider = void 0;
305
330
  var init_sendgrid = __esm({
306
331
  "src/server/providers/sendgrid.ts"() {
332
+ init_webhook_tolerance();
307
333
  SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
308
334
  SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
309
335
  exports.SendGridProvider = class {
@@ -311,10 +337,13 @@ var init_sendgrid = __esm({
311
337
  this.opts = opts;
312
338
  sgMail__default.default.setApiKey(opts.apiKey);
313
339
  this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
340
+ this.webhookToleranceSeconds = resolveWebhookToleranceSeconds(opts.webhookToleranceSeconds);
314
341
  }
315
342
  opts;
316
343
  name = "sendgrid";
317
344
  sendRatePerSecond;
345
+ /** Resolved replay window in seconds; `0` means the check is disabled. */
346
+ webhookToleranceSeconds;
318
347
  async send(args) {
319
348
  const msg = {
320
349
  to: args.to,
@@ -354,10 +383,11 @@ var init_sendgrid = __esm({
354
383
  try {
355
384
  const verifier = crypto2__default.default.createVerify("sha256");
356
385
  verifier.update(payload);
357
- return verifier.verify(this.opts.webhookVerificationKey, sig, "base64");
386
+ if (!verifier.verify(this.opts.webhookVerificationKey, sig, "base64")) return false;
358
387
  } catch {
359
388
  return false;
360
389
  }
390
+ return isWebhookTimestampFresh(ts, this.webhookToleranceSeconds);
361
391
  }
362
392
  parseWebhookEvents(payload) {
363
393
  if (!Array.isArray(payload)) return [];
@@ -487,6 +517,10 @@ var flowStepSchema = zod.z.lazy(
487
517
  }),
488
518
  zod.z.object({
489
519
  type: zod.z.literal("webhook"),
520
+ // `url` is validated for shape only and is deliberately never templated —
521
+ // see the comment on handleWebhookStep in src/server/runner/step.ts, and
522
+ // INVARIANT 16. Rendering contact/event data into this field would turn an
523
+ // admin-authored flow into a server-side request forgery primitive.
490
524
  url: zod.z.string().url(),
491
525
  method: zod.z.enum(["POST", "PUT"]).optional(),
492
526
  payload: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
@@ -580,6 +614,7 @@ var DEFAULTS = {
580
614
  requireDoubleOptIn: false,
581
615
  unsubscribeTokenLifetimeDays: 90,
582
616
  transactionalRespectUnsubscribe: false,
617
+ unsubscribeWriteTimeoutMs: 5e3,
583
618
  doiTemplateSlug: "doi-confirmation",
584
619
  doiTokenLifetimeDays: 7,
585
620
  broadcastConfirmationThreshold: 1e3,
@@ -596,7 +631,9 @@ var DEFAULTS = {
596
631
  trackOpens: true,
597
632
  trackClicks: true,
598
633
  storeTrackingIp: false,
599
- storeRenderedBody: false
634
+ storeRenderedBody: false,
635
+ requireSignedTrackingUrls: false,
636
+ trackingUrlLifetimeDays: 0
600
637
  };
601
638
  var CIRCUIT_BREAKER_DEFAULTS = {
602
639
  hardBounceRatePctTrip: 2,
@@ -613,6 +650,7 @@ function resolveConfig(c) {
613
650
  requireDoubleOptIn: c.requireDoubleOptIn ?? DEFAULTS.requireDoubleOptIn,
614
651
  unsubscribeTokenLifetimeDays: c.unsubscribeTokenLifetimeDays ?? DEFAULTS.unsubscribeTokenLifetimeDays,
615
652
  transactionalRespectUnsubscribe: c.transactionalRespectUnsubscribe ?? DEFAULTS.transactionalRespectUnsubscribe,
653
+ unsubscribeWriteTimeoutMs: c.unsubscribeWriteTimeoutMs ?? DEFAULTS.unsubscribeWriteTimeoutMs,
616
654
  doiTemplateSlug: c.doiTemplateSlug ?? DEFAULTS.doiTemplateSlug,
617
655
  doiTokenLifetimeDays: c.doiTokenLifetimeDays ?? DEFAULTS.doiTokenLifetimeDays,
618
656
  broadcastConfirmationThreshold: c.broadcastConfirmationThreshold ?? DEFAULTS.broadcastConfirmationThreshold,
@@ -630,6 +668,8 @@ function resolveConfig(c) {
630
668
  trackClicks: c.trackClicks ?? DEFAULTS.trackClicks,
631
669
  storeTrackingIp: c.storeTrackingIp ?? DEFAULTS.storeTrackingIp,
632
670
  storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
671
+ requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
672
+ trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
633
673
  circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
634
674
  };
635
675
  }
@@ -832,6 +872,22 @@ function isoDay(d) {
832
872
  function randomId() {
833
873
  return crypto2__default.default.randomBytes(8).toString("hex");
834
874
  }
875
+
876
+ // src/server/provider-lookup.ts
877
+ var PROVIDER_METHODS = ["send", "verifyWebhook", "parseWebhookEvents"];
878
+ function isMailProvider(candidate) {
879
+ if (!candidate || typeof candidate !== "object") return false;
880
+ const p = candidate;
881
+ return PROVIDER_METHODS.every((m) => typeof p[m] === "function");
882
+ }
883
+ function resolveProvider(providers, name) {
884
+ if (typeof name !== "string" || !Object.hasOwn(providers, name)) return null;
885
+ const candidate = providers[name];
886
+ return isMailProvider(candidate) ? candidate : null;
887
+ }
888
+ function registeredProviderNames(providers) {
889
+ return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
890
+ }
835
891
  function signUnsubscribeToken(payload, secret) {
836
892
  const body = JSON.stringify({
837
893
  e: payload.email.toLowerCase(),
@@ -870,6 +926,28 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
870
926
  expiresAt: new Date(body.x)
871
927
  };
872
928
  }
929
+ var TRACKING_SCOPES = ["open", "click"];
930
+ var TRACKING_SIG_LENGTH = 12;
931
+ function trackingMessage(scope, params) {
932
+ const tail = params.linkId === void 0 ? "" : `:${params.linkId}`;
933
+ return `mailery.track.v1:${scope}:${params.sendId}${tail}`;
934
+ }
935
+ function signTrackingToken(scope, params, secret) {
936
+ if (!TRACKING_SCOPES.includes(scope)) {
937
+ throw new Error(`signTrackingToken: unknown scope ${String(scope)}`);
938
+ }
939
+ const mac = crypto2__default.default.createHmac("sha256", secret).update(trackingMessage(scope, params)).digest();
940
+ return b64url(mac).slice(0, TRACKING_SIG_LENGTH);
941
+ }
942
+ function verifyTrackingToken(token, scope, params, secret) {
943
+ if (typeof token !== "string") return false;
944
+ if (!TRACKING_SCOPES.includes(scope)) return false;
945
+ if (token.length !== TRACKING_SIG_LENGTH) return false;
946
+ const expected = Buffer.from(signTrackingToken(scope, params, secret), "utf8");
947
+ const actual = Buffer.from(token, "utf8");
948
+ if (expected.length !== actual.length) return false;
949
+ return crypto2__default.default.timingSafeEqual(expected, actual);
950
+ }
873
951
  function b64url(buf) {
874
952
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
875
953
  }
@@ -913,6 +991,39 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
913
991
  if (body.x < now.getTime()) return null;
914
992
  return { externalId: body.i, expiresAt: new Date(body.x) };
915
993
  }
994
+
995
+ // src/server/unsubscribe.ts
996
+ async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
997
+ const normalized = input.email;
998
+ await collections.suppressions.updateOne(
999
+ { email: normalized, scope: input.scope },
1000
+ {
1001
+ $setOnInsert: {
1002
+ email: normalized,
1003
+ emailHash: sha256Hex(normalized),
1004
+ scope: input.scope,
1005
+ // mailer_suppressions canonical reason — see plans/02-data-model.md.
1006
+ reason: "unsubscribed",
1007
+ source: input.source,
1008
+ notes: input.notes ?? null,
1009
+ addedAt: now,
1010
+ expiresAt: null
1011
+ }
1012
+ },
1013
+ { upsert: true }
1014
+ );
1015
+ await collections.subscriptions.updateOne(
1016
+ { emailAtSubscribe: normalized },
1017
+ {
1018
+ $set: {
1019
+ status: "unsubscribed",
1020
+ unsubscribedAt: now,
1021
+ unsubscribeReason: input.reason,
1022
+ updatedAt: now
1023
+ }
1024
+ }
1025
+ );
1026
+ }
916
1027
  var QUEUE_NAMES = {
917
1028
  tick: "mailer-tick",
918
1029
  advance: "mailer-advance",
@@ -1745,7 +1856,7 @@ async function suppressOnce(ctx, email, reason, scope) {
1745
1856
  }
1746
1857
 
1747
1858
  // src/server/runner/predicate.ts
1748
- var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
1859
+ var DEFAULT_BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
1749
1860
  async function evaluatePredicate(predicate, ctx) {
1750
1861
  const p = predicate;
1751
1862
  if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
@@ -1813,17 +1924,35 @@ async function openOrClickCount(ctx, kind, opts, excludeBots) {
1813
1924
  return await ctx.collections.sends.countDocuments(filter);
1814
1925
  }
1815
1926
  const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
1927
+ const botRe = ctx.botFilter?.userAgentPattern ?? DEFAULT_BOT_UA_RE;
1928
+ const minOpenDelayMs = ctx.botFilter?.minOpenDelayMs ?? 0;
1816
1929
  let n = 0;
1817
1930
  for (const s of docs) {
1818
- if (kind === "opened") {
1819
- n++;
1820
- continue;
1821
- }
1822
- const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
1823
- if (hasHumanClick) n++;
1931
+ const doc = s;
1932
+ const human = kind === "opened" ? hasHumanOpen(doc, botRe, minOpenDelayMs) : (doc.clickedLinks ?? []).some((c) => !isBotUserAgent(c.userAgent, botRe));
1933
+ if (human) n++;
1824
1934
  }
1825
1935
  return n;
1826
1936
  }
1937
+ function isBotUserAgent(ua, botRe) {
1938
+ if (typeof ua !== "string" || ua.trim() === "") return false;
1939
+ return botRe.test(ua);
1940
+ }
1941
+ function hasHumanOpen(send, botRe, minOpenDelayMs) {
1942
+ const opens = send.opens && send.opens.length > 0 ? send.opens : send.openedAt ? [{ openedAt: send.openedAt, userAgent: null }] : [];
1943
+ return opens.some((o) => {
1944
+ if (isBotUserAgent(o.userAgent, botRe)) return false;
1945
+ if (isPrefetchOpen(o.openedAt, send.queuedAt, minOpenDelayMs)) return false;
1946
+ return true;
1947
+ });
1948
+ }
1949
+ function isPrefetchOpen(openedAt, queuedAt, minOpenDelayMs) {
1950
+ if (!minOpenDelayMs || minOpenDelayMs <= 0) return false;
1951
+ if (!(openedAt instanceof Date) || !(queuedAt instanceof Date)) return false;
1952
+ const delta = openedAt.getTime() - queuedAt.getTime();
1953
+ if (!Number.isFinite(delta) || delta < 0) return false;
1954
+ return delta < minOpenDelayMs;
1955
+ }
1827
1956
  function effectiveLowerBound(ctx, opts) {
1828
1957
  const now = ctx.now ?? /* @__PURE__ */ new Date();
1829
1958
  if (opts.sinceFlowStart) return ctx.run.enteredAt;
@@ -1989,12 +2118,14 @@ function applyTracking(html, opts) {
1989
2118
  seen.set(url, linkId);
1990
2119
  links.push({ linkId, url });
1991
2120
  }
1992
- const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}`;
2121
+ const sig = opts.signingSecret ? `/${signTrackingToken("click", { sendId: opts.sendId, linkId }, opts.signingSecret)}` : "";
2122
+ const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}${sig}`;
1993
2123
  return `<a${pre}href="${newHref}"${post}>`;
1994
2124
  });
1995
2125
  }
1996
2126
  if (opts.trackOpens) {
1997
- const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
2127
+ const sig = opts.signingSecret ? `.${signTrackingToken("open", { sendId: opts.sendId }, opts.signingSecret)}` : "";
2128
+ const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}${sig}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
1998
2129
  if (/<\/body>/i.test(out)) {
1999
2130
  out = out.replace(/<\/body>/i, `${pixel}</body>`);
2000
2131
  } else {
@@ -2005,14 +2136,22 @@ function applyTracking(html, opts) {
2005
2136
  }
2006
2137
  function shouldSkipClickRewrite(url, preserve, fullTag) {
2007
2138
  if (!url) return true;
2008
- if (url.startsWith("mailto:")) return true;
2009
- if (url.startsWith("tel:")) return true;
2010
2139
  if (url.startsWith("#")) return true;
2011
2140
  if (url.startsWith("{{") || url.startsWith("{")) return true;
2012
2141
  if (preserve.has(url.trim())) return true;
2013
2142
  if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
2143
+ if (!isHttpUrl(url)) return true;
2014
2144
  return false;
2015
2145
  }
2146
+ function isHttpUrl(url) {
2147
+ let parsed;
2148
+ try {
2149
+ parsed = new URL(url.trim());
2150
+ } catch {
2151
+ return false;
2152
+ }
2153
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
2154
+ }
2016
2155
  var NAMED_ENTITIES = {
2017
2156
  amp: "&",
2018
2157
  lt: "<",
@@ -2233,7 +2372,8 @@ async function dispatchSend(sendId, ctx) {
2233
2372
  publicUrl: ctx.config.publicUrl,
2234
2373
  trackOpens: template.trackOpens ?? ctx.config.trackOpens,
2235
2374
  trackClicks: template.trackClicks ?? ctx.config.trackClicks,
2236
- preserveUrls: [renderCtx.unsubscribeUrl]
2375
+ preserveUrls: [renderCtx.unsubscribeUrl],
2376
+ signingSecret: ctx.config.unsubscribeSecret
2237
2377
  });
2238
2378
  await ctx.collections.sends.updateOne(
2239
2379
  { _id: send._id },
@@ -2251,9 +2391,14 @@ async function dispatchSend(sendId, ctx) {
2251
2391
  }
2252
2392
  }
2253
2393
  );
2254
- const provider = ctx.providers[send.provider] ?? ctx.providers[ctx.config.defaultProvider];
2394
+ const provider = resolveProvider(ctx.providers, send.provider);
2255
2395
  if (!provider) {
2256
- await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
2396
+ const known = registeredProviderNames(ctx.providers);
2397
+ await markFailed(
2398
+ send._id,
2399
+ `provider_unknown: no provider is registered as "${send.provider}". Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
2400
+ ctx
2401
+ );
2257
2402
  return;
2258
2403
  }
2259
2404
  const headers = send.kind === "marketing" ? {
@@ -2452,7 +2597,7 @@ async function handleWait(run, step, ctx) {
2452
2597
  }
2453
2598
  async function handleCondition(run, step, contact, ctx) {
2454
2599
  if (!contact) return;
2455
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
2600
+ const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections, botFilter: ctx.config.botFilter });
2456
2601
  if (result) {
2457
2602
  await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
2458
2603
  return;
@@ -2465,7 +2610,7 @@ async function handleCondition(run, step, contact, ctx) {
2465
2610
  }
2466
2611
  async function handleBranch(run, step, contact, ctx) {
2467
2612
  if (!contact) return;
2468
- const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
2613
+ const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections, botFilter: ctx.config.botFilter });
2469
2614
  const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
2470
2615
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
2471
2616
  { _id: run._id, currentStepIndex: run.currentStepIndex },
@@ -3609,8 +3754,6 @@ function nthSundayOfMonth(year, month0, n) {
3609
3754
  const firstSunday = 1 + (7 - dayOfWeek) % 7;
3610
3755
  return firstSunday + (n - 1) * 7;
3611
3756
  }
3612
-
3613
- // src/server/runner/dmarc.ts
3614
3757
  var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
3615
3758
  function assertSafeZipEntryName(rawName) {
3616
3759
  if (typeof rawName !== "string" || rawName.length === 0) {
@@ -3684,8 +3827,7 @@ async function extractDmarcXmls(buffer, filename) {
3684
3827
  return [buffer.toString("utf8")];
3685
3828
  }
3686
3829
  function parseDmarcReport(xml) {
3687
- const { XMLParser } = __require("fast-xml-parser");
3688
- const parser = new XMLParser({
3830
+ const parser = new fastXmlParser.XMLParser({
3689
3831
  ignoreAttributes: false,
3690
3832
  parseAttributeValue: false,
3691
3833
  parseTagValue: false,
@@ -3775,7 +3917,7 @@ function toArray(v) {
3775
3917
  if (v === void 0 || v === null) return [];
3776
3918
  return Array.isArray(v) ? v : [v];
3777
3919
  }
3778
- async function ingestDmarcAttachment(ctx, buffer, filename) {
3920
+ async function ingestDmarcAttachment(ctx, buffer, filename, opts = {}) {
3779
3921
  const xmls = await extractDmarcXmls(buffer, filename);
3780
3922
  if (xmls.length === 0) throw new Error("attachment contained no XML payload");
3781
3923
  const results = [];
@@ -3783,6 +3925,9 @@ async function ingestDmarcAttachment(ctx, buffer, filename) {
3783
3925
  for (const xml of xmls) {
3784
3926
  try {
3785
3927
  const parsed = parseDmarcReport(xml);
3928
+ if (opts.allowDomain && !opts.allowDomain(parsed.report.domain)) {
3929
+ throw new Error(`DMARC report is for domain "${parsed.report.domain}", which this deployment does not send from`);
3930
+ }
3786
3931
  results.push(await ingestParsedDmarcReport(ctx, parsed));
3787
3932
  } catch (err) {
3788
3933
  if (!firstError) firstError = err;
@@ -3893,6 +4038,260 @@ function suggestPolicyProgression(input) {
3893
4038
  }
3894
4039
  return null;
3895
4040
  }
4041
+ var DRAINING_INFIX = ".draining.";
4042
+ var ADOPT_AFTER_MS = 5 * 60 * 1e3;
4043
+ var JOURNAL_WARN_BYTES = 8 * 1024 * 1024;
4044
+ var O_NOFOLLOW = fs__default.default.constants.O_NOFOLLOW ?? 0;
4045
+ var APPEND_FLAGS = fs__default.default.constants.O_WRONLY | fs__default.default.constants.O_CREAT | fs__default.default.constants.O_APPEND | O_NOFOLLOW;
4046
+ function appendPendingUnsub(journalPath, entry) {
4047
+ fs__default.default.mkdirSync(path__default.default.dirname(journalPath), { recursive: true, mode: 448 });
4048
+ const fd = fs__default.default.openSync(journalPath, APPEND_FLAGS, 384);
4049
+ try {
4050
+ assertPrivateRegularFile(fd, journalPath);
4051
+ fs__default.default.writeSync(fd, serializeEntry(entry));
4052
+ } finally {
4053
+ fs__default.default.closeSync(fd);
4054
+ }
4055
+ }
4056
+ function assertPrivateRegularFile(fd, journalPath) {
4057
+ const st = fs__default.default.fstatSync(fd);
4058
+ if (!st.isFile()) {
4059
+ throw new Error(`pending-unsubscribe journal is not a regular file: ${journalPath}`);
4060
+ }
4061
+ if (st.nlink > 1) {
4062
+ throw new Error(`pending-unsubscribe journal has ${st.nlink} hard links: ${journalPath}`);
4063
+ }
4064
+ if ((st.mode & 63) !== 0) fs__default.default.fchmodSync(fd, 384);
4065
+ }
4066
+ function serializeEntry(entry) {
4067
+ return JSON.stringify(entry) + "\n";
4068
+ }
4069
+ function claimToken() {
4070
+ return `${Date.now().toString(36)}-${crypto2.randomBytes(6).toString("hex")}`;
4071
+ }
4072
+ function claimJournal(journalPath) {
4073
+ const claimed = `${journalPath}${DRAINING_INFIX}${claimToken()}`;
4074
+ try {
4075
+ fs__default.default.renameSync(journalPath, claimed);
4076
+ } catch (err) {
4077
+ if (isMissing(err)) return null;
4078
+ throw err;
4079
+ }
4080
+ markClaimActive(claimed);
4081
+ return claimed;
4082
+ }
4083
+ function markClaimActive(claimPath) {
4084
+ const now = /* @__PURE__ */ new Date();
4085
+ try {
4086
+ fs__default.default.utimesSync(claimPath, now, now);
4087
+ } catch (err) {
4088
+ if (!isMissing(err)) throw err;
4089
+ }
4090
+ }
4091
+ function adoptAbandonedClaims(journalPath, opts = {}) {
4092
+ const olderThanMs = opts.olderThanMs ?? ADOPT_AFTER_MS;
4093
+ const now = opts.now ?? Date.now();
4094
+ const dir = path__default.default.dirname(journalPath);
4095
+ const prefix = path__default.default.basename(journalPath) + DRAINING_INFIX;
4096
+ let names;
4097
+ try {
4098
+ names = fs__default.default.readdirSync(dir);
4099
+ } catch (err) {
4100
+ if (isMissing(err)) return [];
4101
+ throw err;
4102
+ }
4103
+ const adopted = [];
4104
+ for (const name of names) {
4105
+ if (!name.startsWith(prefix)) continue;
4106
+ const full = path__default.default.join(dir, name);
4107
+ let st;
4108
+ try {
4109
+ st = fs__default.default.statSync(full);
4110
+ } catch (err) {
4111
+ if (isMissing(err)) continue;
4112
+ throw err;
4113
+ }
4114
+ if (!st.isFile()) continue;
4115
+ if (now - st.mtimeMs < olderThanMs) continue;
4116
+ const mine = `${journalPath}${DRAINING_INFIX}${claimToken()}`;
4117
+ try {
4118
+ fs__default.default.renameSync(full, mine);
4119
+ markClaimActive(mine);
4120
+ adopted.push(mine);
4121
+ } catch (err) {
4122
+ if (!isMissing(err)) throw err;
4123
+ }
4124
+ }
4125
+ return adopted;
4126
+ }
4127
+ function readClaim(claimPath) {
4128
+ let raw;
4129
+ try {
4130
+ raw = fs__default.default.readFileSync(claimPath, "utf8");
4131
+ } catch (err) {
4132
+ if (isMissing(err)) return { entries: [], malformed: [], bytes: 0 };
4133
+ throw err;
4134
+ }
4135
+ const entries = [];
4136
+ const malformed = [];
4137
+ const lines = raw.split("\n");
4138
+ for (let i = 0; i < lines.length; i++) {
4139
+ const line = lines[i];
4140
+ if (line.trim() === "") continue;
4141
+ const parsed = parseEntry(line);
4142
+ if (parsed) entries.push(parsed);
4143
+ else malformed.push({ lineNumber: i + 1, preview: line.slice(0, 120) });
4144
+ }
4145
+ return { entries, malformed, bytes: Buffer.byteLength(raw) };
4146
+ }
4147
+ function parseEntry(line) {
4148
+ let obj;
4149
+ try {
4150
+ obj = JSON.parse(line);
4151
+ } catch {
4152
+ return null;
4153
+ }
4154
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
4155
+ const o = obj;
4156
+ const check = unsubscribeInputSchema.safeParse({
4157
+ email: o.email,
4158
+ scope: o.scope,
4159
+ reason: "user_request",
4160
+ source: "one-click"
4161
+ });
4162
+ if (!check.success) return null;
4163
+ const at = typeof o.at === "number" && Number.isFinite(o.at) ? o.at : Date.now();
4164
+ const attempts = typeof o.attempts === "number" && Number.isFinite(o.attempts) && o.attempts > 0 ? Math.floor(o.attempts) : void 0;
4165
+ return {
4166
+ email: check.data.email,
4167
+ scope: check.data.scope,
4168
+ at,
4169
+ ...attempts === void 0 ? {} : { attempts }
4170
+ };
4171
+ }
4172
+ function entryToInput(entry) {
4173
+ return unsubscribeInputSchema.parse({
4174
+ email: entry.email,
4175
+ scope: entry.scope,
4176
+ reason: "user_request",
4177
+ // Distinguishable in `mailer_suppressions.source` from a live one-click,
4178
+ // but only on rows the drain actually created (the upsert is
4179
+ // `$setOnInsert`, so a row the live path already wrote keeps its own).
4180
+ source: "one-click-drain"
4181
+ });
4182
+ }
4183
+ function releaseClaim(journalPath, claimPath, remaining) {
4184
+ if (remaining.length > 0) {
4185
+ fs__default.default.mkdirSync(path__default.default.dirname(journalPath), { recursive: true, mode: 448 });
4186
+ const fd = fs__default.default.openSync(journalPath, APPEND_FLAGS, 384);
4187
+ try {
4188
+ assertPrivateRegularFile(fd, journalPath);
4189
+ fs__default.default.writeSync(
4190
+ fd,
4191
+ remaining.map((e) => serializeEntry({ ...e, attempts: (e.attempts ?? 0) + 1 })).join("")
4192
+ );
4193
+ } finally {
4194
+ fs__default.default.closeSync(fd);
4195
+ }
4196
+ }
4197
+ try {
4198
+ fs__default.default.unlinkSync(claimPath);
4199
+ } catch (err) {
4200
+ if (!isMissing(err)) throw err;
4201
+ }
4202
+ }
4203
+ function journalExists(journalPath) {
4204
+ try {
4205
+ return fs__default.default.statSync(journalPath).isFile();
4206
+ } catch (err) {
4207
+ if (isMissing(err)) return false;
4208
+ throw err;
4209
+ }
4210
+ }
4211
+ function isMissing(err) {
4212
+ const code = err?.code;
4213
+ return code === "ENOENT" || code === "ENOTDIR";
4214
+ }
4215
+
4216
+ // src/server/runner/pending-unsubs.ts
4217
+ var MAX_ENTRIES_PER_PASS = 1e3;
4218
+ var NOISY_AFTER_ATTEMPTS = 10;
4219
+ var consoleLog = {
4220
+ warn: (fields, msg) => console.warn(msg ?? "mailery", fields),
4221
+ error: (fields, msg) => console.error(msg ?? "mailery", fields)
4222
+ };
4223
+ async function drainPendingUnsubscribes(ctx, opts = {}) {
4224
+ const journalPath = opts.path ?? ctx.config.pendingUnsubsPath;
4225
+ const result = {
4226
+ enabled: Boolean(journalPath),
4227
+ applied: 0,
4228
+ deferred: 0,
4229
+ malformed: 0,
4230
+ batches: 0
4231
+ };
4232
+ if (!journalPath) return result;
4233
+ const log = opts.log ?? consoleLog;
4234
+ const budget = opts.maxEntries ?? MAX_ENTRIES_PER_PASS;
4235
+ const claims = [];
4236
+ try {
4237
+ claims.push(...adoptAbandonedClaims(journalPath));
4238
+ } catch (err) {
4239
+ log.error?.({ err, path: journalPath }, "mailery: pending-unsub claim adoption failed");
4240
+ }
4241
+ if (journalExists(journalPath)) {
4242
+ const claimed = claimJournal(journalPath);
4243
+ if (claimed) claims.push(claimed);
4244
+ }
4245
+ if (claims.length === 0) return result;
4246
+ let remainingBudget = budget;
4247
+ for (const claimPath of claims) {
4248
+ result.batches++;
4249
+ const batch = readClaim(claimPath);
4250
+ if (batch.bytes > JOURNAL_WARN_BYTES) {
4251
+ log.warn?.(
4252
+ { path: claimPath, bytes: batch.bytes, entries: batch.entries.length },
4253
+ "mailery: pending-unsubscribe journal is large \u2014 Mongo has been unreachable for a while"
4254
+ );
4255
+ }
4256
+ for (const bad of batch.malformed) {
4257
+ result.malformed++;
4258
+ log.warn?.(
4259
+ { path: claimPath, lineNumber: bad.lineNumber, preview: bad.preview },
4260
+ "mailery: pending-unsubscribe journal line unusable \u2014 skipped"
4261
+ );
4262
+ }
4263
+ const deferred = [];
4264
+ let stop = false;
4265
+ for (let i = 0; i < batch.entries.length; i++) {
4266
+ const entry = batch.entries[i];
4267
+ if (stop || remainingBudget <= 0) {
4268
+ deferred.push(entry);
4269
+ continue;
4270
+ }
4271
+ try {
4272
+ await applyUnsubscribe(ctx.collections, entryToInput(entry));
4273
+ result.applied++;
4274
+ remainingBudget--;
4275
+ } catch (err) {
4276
+ stop = true;
4277
+ deferred.push(entry);
4278
+ const attempts = (entry.attempts ?? 0) + 1;
4279
+ const fields = { err, path: claimPath, attempts, pending: batch.entries.length - i };
4280
+ if (attempts >= NOISY_AFTER_ATTEMPTS) {
4281
+ log.error?.(
4282
+ fields,
4283
+ "mailery: pending unsubscribe still not applied after repeated attempts \u2014 investigate"
4284
+ );
4285
+ } else {
4286
+ log.warn?.(fields, "mailery: pending-unsubscribe replay failed \u2014 deferred to next tick");
4287
+ }
4288
+ }
4289
+ }
4290
+ result.deferred += deferred.length;
4291
+ releaseClaim(journalPath, claimPath, deferred);
4292
+ }
4293
+ return result;
4294
+ }
3896
4295
 
3897
4296
  // src/server/runner/tick.ts
3898
4297
  var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
@@ -3934,6 +4333,9 @@ async function runTick(ctx) {
3934
4333
  await drainOutbox(ctx).catch((err) => {
3935
4334
  console.error("mailery: outbox drain failed", err);
3936
4335
  });
4336
+ await drainPendingUnsubscribes(ctx).catch((err) => {
4337
+ console.error("mailery: pending-unsubscribe drain failed", err);
4338
+ });
3937
4339
  await processScheduledBroadcasts2(ctx).catch((err) => {
3938
4340
  console.error("mailery: broadcast dispatch failed", err);
3939
4341
  });
@@ -4066,6 +4468,10 @@ var Mailer = class _Mailer {
4066
4468
  * MAILER_FROM_NAME / MAILER_FROM_EMAIL
4067
4469
  * MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
4068
4470
  * MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
4471
+ * MAILER_SENDGRID_WEBHOOK_TOLERANCE — signed-webhook replay window, seconds
4472
+ * (default 300; '0' disables the check).
4473
+ * Unparseable values fall back to the
4474
+ * default rather than disabling it.
4069
4475
  * MAILER_HOST_USERS_COLLECTION (default 'users')
4070
4476
  * MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
4071
4477
  * MAILER_HOST_USERS_ID_FIELD (default '_id')
@@ -4100,6 +4506,10 @@ var Mailer = class _Mailer {
4100
4506
  providers.sendgrid = new SendGridProvider2({
4101
4507
  apiKey: env.MAILER_SENDGRID_API_KEY,
4102
4508
  webhookVerificationKey: env.MAILER_SENDGRID_WEBHOOK_KEY,
4509
+ // Left undefined when unset/blank so the provider default (300s) applies.
4510
+ // resolveWebhookToleranceSeconds() rejects NaN back to the default, so a
4511
+ // typo here can never silently switch the replay check off.
4512
+ webhookToleranceSeconds: env.MAILER_SENDGRID_WEBHOOK_TOLERANCE ? Number(env.MAILER_SENDGRID_WEBHOOK_TOLERANCE) : void 0,
4103
4513
  sandbox: env.NODE_ENV !== "production"
4104
4514
  });
4105
4515
  }
@@ -4127,8 +4537,15 @@ var Mailer = class _Mailer {
4127
4537
  }
4128
4538
  static async init(input) {
4129
4539
  const config = resolveConfig(input);
4130
- if (!config.providers[config.defaultProvider]) {
4131
- throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
4540
+ if (!resolveProvider(config.providers, config.defaultProvider)) {
4541
+ throw new Error(
4542
+ `defaultProvider "${config.defaultProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
4543
+ );
4544
+ }
4545
+ if (config.defaultTransactionalProvider != null && !resolveProvider(config.providers, config.defaultTransactionalProvider)) {
4546
+ throw new Error(
4547
+ `defaultTransactionalProvider "${config.defaultTransactionalProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
4548
+ );
4132
4549
  }
4133
4550
  if (config.varsAdapter) {
4134
4551
  const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
@@ -4270,38 +4687,14 @@ var Mailer = class _Mailer {
4270
4687
  }
4271
4688
  }
4272
4689
  }
4690
+ /**
4691
+ * The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
4692
+ * drain (INVARIANT 8) replays a journaled opt-out through exactly this path
4693
+ * without holding a `Mailer` instance.
4694
+ */
4273
4695
  async unsubscribe(email, opts) {
4274
4696
  const parsed = unsubscribeInputSchema.parse({ email, ...opts });
4275
- const normalized = parsed.email;
4276
- const now = /* @__PURE__ */ new Date();
4277
- await this.collections.suppressions.updateOne(
4278
- { email: normalized, scope: parsed.scope },
4279
- {
4280
- $setOnInsert: {
4281
- email: normalized,
4282
- emailHash: sha256Hex(normalized),
4283
- scope: parsed.scope,
4284
- // mailer_suppressions canonical reason — see plans/02-data-model.md.
4285
- reason: "unsubscribed",
4286
- source: parsed.source,
4287
- notes: parsed.notes ?? null,
4288
- addedAt: now,
4289
- expiresAt: null
4290
- }
4291
- },
4292
- { upsert: true }
4293
- );
4294
- await this.collections.subscriptions.updateOne(
4295
- { emailAtSubscribe: normalized },
4296
- {
4297
- $set: {
4298
- status: "unsubscribed",
4299
- unsubscribedAt: now,
4300
- unsubscribeReason: parsed.reason,
4301
- updatedAt: now
4302
- }
4303
- }
4304
- );
4697
+ await applyUnsubscribe(this.collections, parsed);
4305
4698
  }
4306
4699
  async suppress(email, opts) {
4307
4700
  const parsed = suppressInputSchema.parse({ email, ...opts });
@@ -4637,8 +5030,20 @@ var NullProvider = class {
4637
5030
  status: "accepted"
4638
5031
  };
4639
5032
  }
5033
+ /**
5034
+ * Fails closed. This provider has no signing key, so it can never establish
5035
+ * that an inbound payload is authentic — and a method whose entire job is to
5036
+ * reject unauthenticated input must not answer "yes" by default. Returning
5037
+ * `true` here would mean any unsigned body posted to the webhook route is
5038
+ * accepted as genuine whenever the null provider is registered, which is
5039
+ * exactly the dev/staging configuration where that is easiest to miss.
5040
+ *
5041
+ * Nothing is lost: `parseWebhookEvents()` returns `[]`, so there was never
5042
+ * any inbound behaviour to preserve. To exercise the inbound half locally,
5043
+ * use a provider that actually signs (see `test/README.md`).
5044
+ */
4640
5045
  async verifyWebhook() {
4641
- return true;
5046
+ return false;
4642
5047
  }
4643
5048
  parseWebhookEvents() {
4644
5049
  return [];
@@ -6780,11 +7185,11 @@ function apiRouter(mailer, opts = {}) {
6780
7185
  if (cached) {
6781
7186
  return res.json({ cached: true, status: "ready", score: cached });
6782
7187
  }
6783
- const { checkId, emailAddress } = await client.provisionCheck();
6784
- const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
7188
+ const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
6785
7189
  if (!provider) {
6786
- return res.status(500).json({ error: "provider_unknown", message: `default provider ${mailer.config.defaultProvider} is not registered` });
7190
+ return res.status(400).json(unknownProviderError(mailer, tpl));
6787
7191
  }
7192
+ const { checkId, emailAddress } = await client.provisionCheck();
6788
7193
  try {
6789
7194
  await provider.send({
6790
7195
  to: emailAddress,
@@ -7133,7 +7538,10 @@ function apiRouter(mailer, opts = {}) {
7133
7538
  trackOpens: false,
7134
7539
  trackClicks: false
7135
7540
  });
7136
- const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
7541
+ const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
7542
+ if (!provider) {
7543
+ return res.status(400).json(unknownProviderError(mailer, tpl));
7544
+ }
7137
7545
  const result = await provider.send({
7138
7546
  to,
7139
7547
  fromName: rendered.fromName,
@@ -7285,6 +7693,18 @@ function asyncHandler(fn) {
7285
7693
  fn(req, res, next).catch(next);
7286
7694
  };
7287
7695
  }
7696
+ function unknownProviderError(mailer, tpl) {
7697
+ const override = tpl.providerOverride ?? null;
7698
+ const name = override ?? mailer.config.defaultProvider;
7699
+ const known = registeredProviderNames(mailer.providers);
7700
+ const source = override ? `template "${tpl.slug ?? "?"}" overrides the provider to "${name}"` : `the configured defaultProvider is "${name}"`;
7701
+ return {
7702
+ error: "provider_unknown",
7703
+ message: `${source}, but no such provider is registered. Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
7704
+ provider: name,
7705
+ registeredProviders: known
7706
+ };
7707
+ }
7288
7708
  async function collectQueueCounts(mailer) {
7289
7709
  const qs = Object.values(mailer.queues);
7290
7710
  const sum = async (key) => {
@@ -7420,13 +7840,227 @@ async function computeBroadcastStats(mailer, idFilter) {
7420
7840
  }
7421
7841
  return out;
7422
7842
  }
7843
+
7844
+ // src/server/api/wrap.ts
7845
+ var consoleRouteLogger = {
7846
+ error(fields, msg) {
7847
+ console.error(msg ?? "mailery: request failed", fields);
7848
+ },
7849
+ warn(fields, msg) {
7850
+ console.warn(msg ?? "mailery: request warning", fields);
7851
+ },
7852
+ info(fields, msg) {
7853
+ console.info(msg ?? "mailery: request", fields);
7854
+ }
7855
+ };
7856
+ function wrap(logger, handler) {
7857
+ return async (req, res, next) => {
7858
+ try {
7859
+ await handler(req, res, next);
7860
+ } catch (err) {
7861
+ logger.error?.({ err, path: req.originalUrl }, "mailery: request failed");
7862
+ if (res.headersSent) return;
7863
+ res.status(500).json({ error: "internal_error" });
7864
+ }
7865
+ };
7866
+ }
7867
+
7868
+ // src/server/api/dmarc-inbound.ts
7869
+ var ALLOWED_EXTENSIONS = [".zip", ".gz", ".xml"];
7870
+ var DEFAULT_PATH = "/inbound/dmarc";
7871
+ var DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
7872
+ var DEFAULT_MAX_FILES = 10;
7873
+ function mountDmarcInbound(router, mailer, opts, logger) {
7874
+ const secret = opts?.secret;
7875
+ if (!opts || typeof secret !== "string" || secret.length === 0) return null;
7876
+ const routePath = opts.path ?? DEFAULT_PATH;
7877
+ const parseInbound = opts.parseInbound ?? sendgridInboundParser;
7878
+ const allowedDomains = normalizeDomains(opts.allowedDomains ?? deriveSenderDomains(mailer));
7879
+ if (allowedDomains.size === 0) {
7880
+ logger.warn?.(
7881
+ { path: routePath },
7882
+ "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"
7883
+ );
7884
+ }
7885
+ const upload = multer__default.default({
7886
+ storage: multer__default.default.memoryStorage(),
7887
+ limits: {
7888
+ fileSize: opts.maxFileSizeBytes ?? DEFAULT_MAX_FILE_BYTES,
7889
+ files: opts.maxFiles ?? DEFAULT_MAX_FILES,
7890
+ // Inbound Parse sends the whole email as form fields (headers, text,
7891
+ // html, ...). We read none of them, but they still have to be bounded.
7892
+ fields: 40,
7893
+ fieldSize: 1024 * 1024
7894
+ }
7895
+ });
7896
+ router.post(
7897
+ routePath,
7898
+ wrap(logger, (req, res, next) => {
7899
+ if (!isAuthorized(req, secret)) {
7900
+ logger.warn?.(
7901
+ { path: routePath, ip: req.ip },
7902
+ "mailery: DMARC inbound request rejected \u2014 bad or missing shared secret"
7903
+ );
7904
+ res.status(401).json({ error: "unauthorized" });
7905
+ return;
7906
+ }
7907
+ next();
7908
+ }),
7909
+ (req, res, next) => {
7910
+ upload.any()(req, res, (err) => {
7911
+ if (!err) return next();
7912
+ const code = err?.code;
7913
+ const status = code === "LIMIT_FILE_SIZE" || code === "LIMIT_FILE_COUNT" ? 413 : 400;
7914
+ logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound upload rejected");
7915
+ res.status(status).json({ error: "upload_rejected", message: String(code ?? "malformed_multipart") });
7916
+ });
7917
+ },
7918
+ wrap(logger, async (req, res) => {
7919
+ let attachments;
7920
+ try {
7921
+ attachments = parseInbound(req);
7922
+ } catch (err) {
7923
+ logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound payload unreadable");
7924
+ return res.status(400).json({ error: "bad_payload" });
7925
+ }
7926
+ const candidates = attachments.filter((a) => hasAllowedExtension(a.filename));
7927
+ if (candidates.length === 0) {
7928
+ logger.info?.(
7929
+ { path: routePath, attachments: attachments.length },
7930
+ "mailery: DMARC inbound message carried no report attachment \u2014 ignored"
7931
+ );
7932
+ return res.status(200).json({ ok: true, ingested: 0, ignored: attachments.length });
7933
+ }
7934
+ const ctx = mailer.getRunnerContext();
7935
+ const allowDomain = allowedDomains.size === 0 ? void 0 : (d) => allowedDomains.has(d.toLowerCase());
7936
+ const ingested = [];
7937
+ const rejected = [];
7938
+ for (const file of candidates) {
7939
+ try {
7940
+ const result = await ingestDmarcAttachment(ctx, file.buffer, file.filename, { allowDomain });
7941
+ ingested.push({ reportId: result.reportId, domain: result.domain, duplicate: result.duplicate });
7942
+ } catch (err) {
7943
+ const message = String(err?.message ?? err);
7944
+ rejected.push({ filename: file.filename, message });
7945
+ logger.warn?.(
7946
+ { path: routePath, filename: file.filename, err },
7947
+ "mailery: DMARC inbound attachment rejected"
7948
+ );
7949
+ }
7950
+ }
7951
+ if (ingested.length === 0) {
7952
+ return res.status(400).json({ error: "ingest_failed", rejected });
7953
+ }
7954
+ return res.status(200).json({ ok: true, ingested, rejected });
7955
+ })
7956
+ );
7957
+ return routePath;
7958
+ }
7959
+ function isAuthorized(req, secret) {
7960
+ const presented = extractPresentedSecret(req);
7961
+ if (presented === null) return false;
7962
+ return constantTimeEquals(presented, secret);
7963
+ }
7964
+ function extractPresentedSecret(req) {
7965
+ const header = req.headers.authorization;
7966
+ if (typeof header !== "string") return null;
7967
+ const [scheme, ...rest] = header.split(" ");
7968
+ const value = rest.join(" ").trim();
7969
+ if (!scheme || value === "") return null;
7970
+ switch (scheme.toLowerCase()) {
7971
+ case "basic": {
7972
+ let decoded;
7973
+ try {
7974
+ decoded = Buffer.from(value, "base64").toString("utf8");
7975
+ } catch {
7976
+ return null;
7977
+ }
7978
+ const sep = decoded.indexOf(":");
7979
+ return sep === -1 ? null : decoded.slice(sep + 1);
7980
+ }
7981
+ case "bearer":
7982
+ return value;
7983
+ default:
7984
+ return null;
7985
+ }
7986
+ }
7987
+ function constantTimeEquals(a, b) {
7988
+ const ha = crypto2.createHash("sha256").update(a, "utf8").digest();
7989
+ const hb = crypto2.createHash("sha256").update(b, "utf8").digest();
7990
+ return crypto2.timingSafeEqual(ha, hb);
7991
+ }
7992
+ var sendgridInboundParser = (req) => {
7993
+ const files = req.files;
7994
+ if (!Array.isArray(files)) return [];
7995
+ return files.filter((f) => Boolean(f) && Buffer.isBuffer(f.buffer)).map((f) => ({ filename: safeFilename(f.originalname), buffer: f.buffer }));
7996
+ };
7997
+ function safeFilename(raw) {
7998
+ if (typeof raw !== "string") return "";
7999
+ return raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/[\\/]/g, "_").slice(0, 255);
8000
+ }
8001
+ function hasAllowedExtension(filename) {
8002
+ const lower = filename.toLowerCase();
8003
+ return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
8004
+ }
8005
+ function normalizeDomains(domains) {
8006
+ const out = /* @__PURE__ */ new Set();
8007
+ for (const d of domains) {
8008
+ if (typeof d !== "string") continue;
8009
+ const trimmed = d.trim().toLowerCase().replace(/^\.+/, "");
8010
+ if (trimmed) out.add(trimmed);
8011
+ }
8012
+ return out;
8013
+ }
8014
+ function deriveSenderDomains(mailer) {
8015
+ const out = [];
8016
+ const push = (domain) => {
8017
+ if (!domain) return;
8018
+ out.push(domain);
8019
+ const labels = domain.split(".");
8020
+ if (labels.length > 2) out.push(labels.slice(-2).join("."));
8021
+ };
8022
+ for (const domain of Object.keys(mailer.config.senderDomains ?? {})) push(domain.toLowerCase());
8023
+ push(emailDomain(mailer.config.fromDefaults?.email));
8024
+ push(emailDomain(mailer.config.transactionalFromDefaults?.email));
8025
+ push(emailDomain(mailer.config.senderAddress));
8026
+ return out;
8027
+ }
8028
+ function emailDomain(email) {
8029
+ if (typeof email !== "string") return void 0;
8030
+ const at = email.lastIndexOf("@");
8031
+ if (at === -1) return void 0;
8032
+ const domain = email.slice(at + 1).trim().toLowerCase();
8033
+ return domain || void 0;
8034
+ }
8035
+
8036
+ // src/server/api/public.ts
7423
8037
  var PIXEL = Buffer.from(
7424
8038
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
7425
8039
  "base64"
7426
8040
  );
7427
8041
  function createPublicRouter(mailer, opts = {}) {
7428
8042
  const router = express.Router();
7429
- const pendingUnsubsPath = opts.pendingUnsubsPath ?? "/tmp/mailery-pending-unsubs.jsonl";
8043
+ const logger = opts.logger ?? consoleRouteLogger;
8044
+ const pendingUnsubsPath = opts.pendingUnsubsPath ?? mailer.config.pendingUnsubsPath ?? null;
8045
+ if (opts.pendingUnsubsPath && !mailer.config.pendingUnsubsPath) {
8046
+ logger.warn?.(
8047
+ { path: opts.pendingUnsubsPath },
8048
+ "mailery: pendingUnsubsPath set on the public router but not in MailerConfig \u2014 the tick drain will not replay this journal"
8049
+ );
8050
+ }
8051
+ if (!pendingUnsubsPath) {
8052
+ logger.warn?.(
8053
+ {},
8054
+ "mailery: no pendingUnsubsPath configured \u2014 POST /unsub answers 503 when Mongo is unreachable instead of journaling the opt-out"
8055
+ );
8056
+ }
8057
+ const dmarcInboundPath = mountDmarcInbound(router, mailer, opts.dmarcInbound, logger);
8058
+ if (dmarcInboundPath) {
8059
+ logger.info?.(
8060
+ { path: dmarcInboundPath },
8061
+ "mailery: DMARC inbound route mounted (shared-secret auth; SendGrid Inbound Parse does not sign payloads)"
8062
+ );
8063
+ }
7430
8064
  router.use(
7431
8065
  "/webhooks",
7432
8066
  express__default.default.json({
@@ -7437,42 +8071,96 @@ function createPublicRouter(mailer, opts = {}) {
7437
8071
  })
7438
8072
  );
7439
8073
  router.use("/unsub", express__default.default.urlencoded({ extended: false }));
7440
- router.get("/open/:sendId.png", async (req, res) => {
8074
+ router.get("/open/:sendId.png", wrap(logger, async (req, res) => {
7441
8075
  res.setHeader("Content-Type", "image/png");
7442
8076
  res.setHeader("Content-Length", String(PIXEL.length));
7443
8077
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
7444
8078
  res.status(200).end(PIXEL);
7445
- const id = req.params.sendId;
8079
+ const parsed = splitOpenParam(req.params.sendId);
8080
+ if (!parsed) return;
8081
+ const { id, sig } = parsed;
7446
8082
  if (!mongodb.ObjectId.isValid(id)) return;
7447
8083
  const sendId = new mongodb.ObjectId(id);
8084
+ const verdict = checkTrackingSignature({
8085
+ scope: "open",
8086
+ params: { sendId: id },
8087
+ sig,
8088
+ secret: mailer.config.unsubscribeSecret,
8089
+ requireSigned: mailer.config.requireSignedTrackingUrls,
8090
+ logger,
8091
+ fields: { sendId: id }
8092
+ });
8093
+ if (verdict === "rejected") return;
7448
8094
  try {
7449
- const send = await mailer.collections.sends.findOne({ _id: sendId }, { projection: { openedAt: 1 } });
8095
+ const send = await mailer.collections.sends.findOne(
8096
+ { _id: sendId },
8097
+ { projection: { openedAt: 1, queuedAt: 1 } }
8098
+ );
7450
8099
  if (!send) return;
8100
+ if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
8101
+ logger.warn?.({ sendId: id }, "mailery: open ignored \u2014 tracking URL past trackingUrlLifetimeDays");
8102
+ return;
8103
+ }
8104
+ const now = /* @__PURE__ */ new Date();
7451
8105
  await mailer.collections.sends.updateOne(
7452
8106
  { _id: sendId },
7453
8107
  {
7454
8108
  $set: {
7455
- openedAt: send.openedAt ?? /* @__PURE__ */ new Date(),
8109
+ openedAt: send.openedAt ?? now,
7456
8110
  status: "delivered"
7457
8111
  },
7458
- $inc: { openCount: 1 }
8112
+ $inc: { openCount: 1 },
8113
+ // Per-open user agent, so `hasOpenedExcludingBots` has a signal to
8114
+ // filter on. Capped: an open pixel can be re-fetched indefinitely
8115
+ // (every time the recipient re-opens the mail) and an unbounded
8116
+ // array would eventually run the send document into the 16MB limit.
8117
+ $push: {
8118
+ opens: {
8119
+ $each: [{ openedAt: now, userAgent: requestUserAgent(req) }],
8120
+ $slice: -MAX_TRACKED_OPENS
8121
+ }
8122
+ }
7459
8123
  }
7460
8124
  );
7461
8125
  } catch (err) {
7462
- console.error("mailery: open pixel update failed", err);
8126
+ logger.error?.({ err, sendId: id }, "mailery: open pixel update failed");
7463
8127
  }
7464
- });
7465
- router.get("/click/:sendId/:linkId", async (req, res) => {
7466
- const { sendId: sendIdStr, linkId } = req.params;
8128
+ }));
8129
+ router.get("/click/:sendId/:linkId{/:sig}", wrap(logger, async (req, res) => {
8130
+ const { sendId: sendIdStr, linkId, sig } = req.params;
7467
8131
  if (!mongodb.ObjectId.isValid(sendIdStr)) return res.status(400).end();
7468
8132
  const sendId = new mongodb.ObjectId(sendIdStr);
8133
+ const verdict = checkTrackingSignature({
8134
+ scope: "click",
8135
+ params: { sendId: sendIdStr, linkId },
8136
+ sig,
8137
+ secret: mailer.config.unsubscribeSecret,
8138
+ requireSigned: mailer.config.requireSignedTrackingUrls,
8139
+ logger,
8140
+ fields: { sendId: sendIdStr, linkId }
8141
+ });
8142
+ if (verdict === "rejected") return res.status(404).end();
7469
8143
  const send = await mailer.collections.sends.findOne(
7470
8144
  { _id: sendId },
7471
- { projection: { links: 1, firstClickAt: 1 } }
8145
+ { projection: { links: 1, firstClickAt: 1, queuedAt: 1 } }
7472
8146
  );
7473
8147
  if (!send) return res.status(404).end();
8148
+ if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
8149
+ logger.warn?.(
8150
+ { sendId: sendIdStr, linkId },
8151
+ "mailery: click rejected \u2014 tracking URL past trackingUrlLifetimeDays"
8152
+ );
8153
+ return res.status(404).end();
8154
+ }
7474
8155
  const link = (send.links ?? []).find((l) => l.linkId === linkId);
7475
8156
  if (!link) return res.status(404).end();
8157
+ if (!isSafeRedirectTarget(link.url)) {
8158
+ logger.warn?.(
8159
+ { sendId: sendIdStr, linkId, url: link.url },
8160
+ "mailery: click redirect blocked \u2014 disallowed URL scheme"
8161
+ );
8162
+ return res.status(400).type("html").send("<!doctype html><html><body><p>This link cannot be opened.</p></body></html>");
8163
+ }
7476
8164
  res.redirect(302, link.url);
7477
8165
  try {
7478
8166
  await mailer.collections.sends.updateOne(
@@ -7484,16 +8172,19 @@ function createPublicRouter(mailer, opts = {}) {
7484
8172
  clickedLinks: {
7485
8173
  url: link.url,
7486
8174
  linkId,
7487
- clickedAt: /* @__PURE__ */ new Date()
8175
+ clickedAt: /* @__PURE__ */ new Date(),
8176
+ // The bot filter has always read `clickedLinks[].userAgent`; until
8177
+ // now nothing ever wrote it, so every click scored as human.
8178
+ userAgent: requestUserAgent(req)
7488
8179
  }
7489
8180
  }
7490
8181
  }
7491
8182
  );
7492
8183
  } catch (err) {
7493
- console.error("mailery: click recording failed", err);
8184
+ logger.error?.({ err, sendId: sendIdStr, linkId }, "mailery: click recording failed");
7494
8185
  }
7495
- });
7496
- router.get("/unsub/:token", (req, res) => {
8186
+ }));
8187
+ router.get("/unsub/:token", wrap(logger, (req, res) => {
7497
8188
  const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
7498
8189
  if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
7499
8190
  res.status(200).type("html").send(`<!doctype html>
@@ -7508,32 +8199,55 @@ function createPublicRouter(mailer, opts = {}) {
7508
8199
  <button type="submit">Unsubscribe</button>
7509
8200
  </form>
7510
8201
  </body></html>`);
7511
- });
7512
- router.post("/unsub/:token", async (req, res) => {
8202
+ }));
8203
+ router.post("/unsub/:token", wrap(logger, async (req, res) => {
7513
8204
  const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
7514
8205
  if (!decoded) {
7515
8206
  res.status(200).end();
7516
8207
  return;
7517
8208
  }
7518
- res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
8209
+ const write = mailer.unsubscribe(decoded.email, {
8210
+ scope: decoded.scope,
8211
+ reason: "user_request",
8212
+ source: "one-click"
8213
+ });
8214
+ let dbError = null;
7519
8215
  try {
7520
- await mailer.unsubscribe(decoded.email, {
7521
- scope: decoded.scope,
7522
- reason: "user_request",
7523
- source: "one-click"
7524
- });
8216
+ await withTimeout(write, mailer.config.unsubscribeWriteTimeoutMs);
7525
8217
  } catch (err) {
8218
+ dbError = err ?? new Error("unsubscribe write failed");
8219
+ void write.catch(() => {
8220
+ });
8221
+ }
8222
+ if (dbError) {
8223
+ if (!pendingUnsubsPath) {
8224
+ logger.error?.(
8225
+ { err: dbError },
8226
+ "mailery: unsubscribe write failed and no pendingUnsubsPath is configured \u2014 answering 503"
8227
+ );
8228
+ return sendUnsubUnavailable(res);
8229
+ }
7526
8230
  try {
7527
- fs__default.default.appendFileSync(
7528
- pendingUnsubsPath,
7529
- JSON.stringify({ email: decoded.email, scope: decoded.scope, at: Date.now() }) + "\n"
8231
+ appendPendingUnsub(pendingUnsubsPath, {
8232
+ email: decoded.email,
8233
+ scope: decoded.scope,
8234
+ at: Date.now()
8235
+ });
8236
+ logger.warn?.(
8237
+ { err: dbError, path: pendingUnsubsPath },
8238
+ "mailery: unsubscribe journaled to disk \u2014 will be replayed by the tick drain"
7530
8239
  );
7531
8240
  } catch (diskErr) {
7532
- console.error("mailery: unsub disk fallback failed", { err, diskErr });
8241
+ logger.error?.(
8242
+ { err: dbError, diskErr, path: pendingUnsubsPath },
8243
+ "mailery: unsub disk fallback failed \u2014 answering 503"
8244
+ );
8245
+ return sendUnsubUnavailable(res);
7533
8246
  }
7534
8247
  }
7535
- });
7536
- router.get("/confirm-doi/:token", async (req, res) => {
8248
+ res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
8249
+ }));
8250
+ router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
7537
8251
  const token = req.params.token;
7538
8252
  const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
7539
8253
  if (!decoded) {
@@ -7561,10 +8275,10 @@ function createPublicRouter(mailer, opts = {}) {
7561
8275
  } catch {
7562
8276
  }
7563
8277
  return res.status(200).type("html").send("<!doctype html><html><body><p>Thanks \u2014 you're subscribed.</p></body></html>");
7564
- });
7565
- router.post("/webhooks/:provider", async (req, res) => {
8278
+ }));
8279
+ router.post("/webhooks/:provider", wrap(logger, async (req, res) => {
7566
8280
  const providerName = req.params.provider;
7567
- const provider = mailer.providers[providerName];
8281
+ const provider = resolveProvider(mailer.providers, providerName);
7568
8282
  if (!provider) return res.status(404).end();
7569
8283
  const rawBody = req.rawBody;
7570
8284
  if (!rawBody) return res.status(400).end();
@@ -7595,7 +8309,10 @@ function createPublicRouter(mailer, opts = {}) {
7595
8309
  { upsert: true }
7596
8310
  );
7597
8311
  } catch (err) {
7598
- console.error("mailery: webhook dedupe insert failed", err);
8312
+ logger.error?.(
8313
+ { err, provider: providerName, providerEventId: evt.providerEventId },
8314
+ "mailery: webhook dedupe insert failed"
8315
+ );
7599
8316
  }
7600
8317
  }
7601
8318
  if (events.length > 0) {
@@ -7604,15 +8321,94 @@ function createPublicRouter(mailer, opts = {}) {
7604
8321
  } catch {
7605
8322
  }
7606
8323
  }
7607
- });
8324
+ }));
7608
8325
  return router;
7609
8326
  }
8327
+ var MAX_TRACKED_OPENS = 50;
8328
+ var MAX_UA_LENGTH = 256;
8329
+ function requestUserAgent(req) {
8330
+ const ua = req.headers["user-agent"];
8331
+ if (typeof ua !== "string" || ua.trim() === "") return null;
8332
+ return ua.slice(0, MAX_UA_LENGTH);
8333
+ }
8334
+ function splitOpenParam(raw) {
8335
+ if (typeof raw !== "string") return null;
8336
+ const parts = raw.split(".");
8337
+ if (parts.length === 1) return { id: parts[0] };
8338
+ if (parts.length === 2) return { id: parts[0], sig: parts[1] };
8339
+ return null;
8340
+ }
8341
+ function checkTrackingSignature(args) {
8342
+ const { sig, logger, fields, scope } = args;
8343
+ if (sig === void 0 || sig === "") {
8344
+ if (args.requireSigned) {
8345
+ logger.warn?.(
8346
+ { ...fields, scope },
8347
+ "mailery: unsigned tracking URL rejected (requireSignedTrackingUrls)"
8348
+ );
8349
+ return "rejected";
8350
+ }
8351
+ logger.info?.(
8352
+ { ...fields, scope },
8353
+ "mailery: unsigned tracking URL accepted \u2014 legacy grace mode"
8354
+ );
8355
+ return "legacy";
8356
+ }
8357
+ if (!verifyTrackingToken(sig, scope, args.params, args.secret)) {
8358
+ logger.warn?.({ ...fields, scope }, "mailery: tracking URL signature invalid");
8359
+ return "rejected";
8360
+ }
8361
+ return "signed";
8362
+ }
8363
+ function isTrackingExpired(send, lifetimeDays, now = /* @__PURE__ */ new Date()) {
8364
+ if (!lifetimeDays || lifetimeDays <= 0) return false;
8365
+ const queuedAt = send.queuedAt;
8366
+ if (!(queuedAt instanceof Date) || Number.isNaN(queuedAt.getTime())) return false;
8367
+ return now.getTime() - queuedAt.getTime() > lifetimeDays * 24 * 60 * 60 * 1e3;
8368
+ }
7610
8369
  function sendUnsubError(res, msg) {
7611
8370
  return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
7612
8371
  }
8372
+ function sendUnsubUnavailable(res) {
8373
+ return res.status(503).set("Retry-After", "60").type("html").send(
8374
+ "<!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>"
8375
+ );
8376
+ }
8377
+ function withTimeout(p, ms) {
8378
+ if (!Number.isFinite(ms) || ms <= 0) return p;
8379
+ return new Promise((resolve, reject) => {
8380
+ const timer = setTimeout(() => {
8381
+ reject(new Error(`timed out after ${ms}ms`));
8382
+ }, ms);
8383
+ if (typeof timer.unref === "function") timer.unref();
8384
+ p.then(
8385
+ (v) => {
8386
+ clearTimeout(timer);
8387
+ resolve(v);
8388
+ },
8389
+ (err) => {
8390
+ clearTimeout(timer);
8391
+ reject(err);
8392
+ }
8393
+ );
8394
+ });
8395
+ }
7613
8396
  function escapeHtml(s) {
7614
8397
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7615
8398
  }
8399
+ var ALLOWED_REDIRECT_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
8400
+ function isSafeRedirectTarget(raw) {
8401
+ if (typeof raw !== "string") return false;
8402
+ const trimmed = raw.trim();
8403
+ if (trimmed === "") return false;
8404
+ let parsed;
8405
+ try {
8406
+ parsed = new URL(trimmed);
8407
+ } catch {
8408
+ return false;
8409
+ }
8410
+ return ALLOWED_REDIRECT_PROTOCOLS.has(parsed.protocol);
8411
+ }
7616
8412
  function lowercaseHeaders(h) {
7617
8413
  const out = {};
7618
8414
  for (const k of Object.keys(h)) {
@@ -7781,14 +8577,16 @@ var DEDUPE_POLICIES = [
7781
8577
  ];
7782
8578
 
7783
8579
  // src/server/index.ts
7784
- var VERSION = "0.14.0" ;
8580
+ var VERSION = "0.15.0" ;
7785
8581
 
7786
8582
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
8583
+ exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;
7787
8584
  exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
7788
8585
  exports.Mailer = Mailer;
7789
8586
  exports.NullProvider = NullProvider;
7790
8587
  exports.PREDICATE_KINDS = PREDICATE_KINDS;
7791
8588
  exports.SEGMENT_FILTER_KINDS = SEGMENT_FILTER_KINDS;
8589
+ exports.TRACKING_SIG_LENGTH = TRACKING_SIG_LENGTH;
7792
8590
  exports.VERSION = VERSION;
7793
8591
  exports.applyTracking = applyTracking;
7794
8592
  exports.applyWebhookEvent = applyWebhookEvent;
@@ -7803,6 +8601,7 @@ exports.defaultSegmentFilter = defaultSegmentFilter;
7803
8601
  exports.defineVars = defineVars;
7804
8602
  exports.derivePlaintext = derivePlaintext;
7805
8603
  exports.dispatchSend = dispatchSend;
8604
+ exports.drainPendingUnsubscribes = drainPendingUnsubscribes;
7806
8605
  exports.ensureIndexes = ensureIndexes;
7807
8606
  exports.getCollections = getCollections;
7808
8607
  exports.predicateKind = predicateKind;
@@ -7810,11 +8609,14 @@ exports.processNewlyFiredEventTriggers = processNewlyFiredEventTriggers;
7810
8609
  exports.processOneRunStep = processOneRunStep;
7811
8610
  exports.renderTemplate = renderTemplate;
7812
8611
  exports.runTick = runTick;
8612
+ exports.sendgridInboundParser = sendgridInboundParser;
7813
8613
  exports.sha256Hex = sha256Hex;
8614
+ exports.signTrackingToken = signTrackingToken;
7814
8615
  exports.signUnsubscribeToken = signUnsubscribeToken;
7815
8616
  exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
7816
8617
  exports.validateSenderDomain = validateSenderDomain;
7817
8618
  exports.varsJsonSchema = varsJsonSchema;
8619
+ exports.verifyTrackingToken = verifyTrackingToken;
7818
8620
  exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
7819
8621
  //# sourceMappingURL=index.cjs.map
7820
8622
  //# sourceMappingURL=index.cjs.map