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.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 [];
@@ -6762,11 +7167,11 @@ function apiRouter(mailer, opts = {}) {
6762
7167
  if (cached) {
6763
7168
  return res.json({ cached: true, status: "ready", score: cached });
6764
7169
  }
6765
- const { checkId, emailAddress } = await client.provisionCheck();
6766
- const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
7170
+ const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
6767
7171
  if (!provider) {
6768
- return res.status(500).json({ error: "provider_unknown", message: `default provider ${mailer.config.defaultProvider} is not registered` });
7172
+ return res.status(400).json(unknownProviderError(mailer, tpl));
6769
7173
  }
7174
+ const { checkId, emailAddress } = await client.provisionCheck();
6770
7175
  try {
6771
7176
  await provider.send({
6772
7177
  to: emailAddress,
@@ -7115,7 +7520,10 @@ function apiRouter(mailer, opts = {}) {
7115
7520
  trackOpens: false,
7116
7521
  trackClicks: false
7117
7522
  });
7118
- const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
7523
+ const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
7524
+ if (!provider) {
7525
+ return res.status(400).json(unknownProviderError(mailer, tpl));
7526
+ }
7119
7527
  const result = await provider.send({
7120
7528
  to,
7121
7529
  fromName: rendered.fromName,
@@ -7267,6 +7675,18 @@ function asyncHandler(fn) {
7267
7675
  fn(req, res, next).catch(next);
7268
7676
  };
7269
7677
  }
7678
+ function unknownProviderError(mailer, tpl) {
7679
+ const override = tpl.providerOverride ?? null;
7680
+ const name = override ?? mailer.config.defaultProvider;
7681
+ const known = registeredProviderNames(mailer.providers);
7682
+ const source = override ? `template "${tpl.slug ?? "?"}" overrides the provider to "${name}"` : `the configured defaultProvider is "${name}"`;
7683
+ return {
7684
+ error: "provider_unknown",
7685
+ message: `${source}, but no such provider is registered. Registered providers: ${known.length ? known.join(", ") : "(none)"}.`,
7686
+ provider: name,
7687
+ registeredProviders: known
7688
+ };
7689
+ }
7270
7690
  async function collectQueueCounts(mailer) {
7271
7691
  const qs = Object.values(mailer.queues);
7272
7692
  const sum = async (key) => {
@@ -7402,13 +7822,227 @@ async function computeBroadcastStats(mailer, idFilter) {
7402
7822
  }
7403
7823
  return out;
7404
7824
  }
7825
+
7826
+ // src/server/api/wrap.ts
7827
+ var consoleRouteLogger = {
7828
+ error(fields, msg) {
7829
+ console.error(msg ?? "mailery: request failed", fields);
7830
+ },
7831
+ warn(fields, msg) {
7832
+ console.warn(msg ?? "mailery: request warning", fields);
7833
+ },
7834
+ info(fields, msg) {
7835
+ console.info(msg ?? "mailery: request", fields);
7836
+ }
7837
+ };
7838
+ function wrap(logger, handler) {
7839
+ return async (req, res, next) => {
7840
+ try {
7841
+ await handler(req, res, next);
7842
+ } catch (err) {
7843
+ logger.error?.({ err, path: req.originalUrl }, "mailery: request failed");
7844
+ if (res.headersSent) return;
7845
+ res.status(500).json({ error: "internal_error" });
7846
+ }
7847
+ };
7848
+ }
7849
+
7850
+ // src/server/api/dmarc-inbound.ts
7851
+ var ALLOWED_EXTENSIONS = [".zip", ".gz", ".xml"];
7852
+ var DEFAULT_PATH = "/inbound/dmarc";
7853
+ var DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
7854
+ var DEFAULT_MAX_FILES = 10;
7855
+ function mountDmarcInbound(router, mailer, opts, logger) {
7856
+ const secret = opts?.secret;
7857
+ if (!opts || typeof secret !== "string" || secret.length === 0) return null;
7858
+ const routePath = opts.path ?? DEFAULT_PATH;
7859
+ const parseInbound = opts.parseInbound ?? sendgridInboundParser;
7860
+ const allowedDomains = normalizeDomains(opts.allowedDomains ?? deriveSenderDomains(mailer));
7861
+ if (allowedDomains.size === 0) {
7862
+ logger.warn?.(
7863
+ { path: routePath },
7864
+ "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"
7865
+ );
7866
+ }
7867
+ const upload = multer({
7868
+ storage: multer.memoryStorage(),
7869
+ limits: {
7870
+ fileSize: opts.maxFileSizeBytes ?? DEFAULT_MAX_FILE_BYTES,
7871
+ files: opts.maxFiles ?? DEFAULT_MAX_FILES,
7872
+ // Inbound Parse sends the whole email as form fields (headers, text,
7873
+ // html, ...). We read none of them, but they still have to be bounded.
7874
+ fields: 40,
7875
+ fieldSize: 1024 * 1024
7876
+ }
7877
+ });
7878
+ router.post(
7879
+ routePath,
7880
+ wrap(logger, (req, res, next) => {
7881
+ if (!isAuthorized(req, secret)) {
7882
+ logger.warn?.(
7883
+ { path: routePath, ip: req.ip },
7884
+ "mailery: DMARC inbound request rejected \u2014 bad or missing shared secret"
7885
+ );
7886
+ res.status(401).json({ error: "unauthorized" });
7887
+ return;
7888
+ }
7889
+ next();
7890
+ }),
7891
+ (req, res, next) => {
7892
+ upload.any()(req, res, (err) => {
7893
+ if (!err) return next();
7894
+ const code = err?.code;
7895
+ const status = code === "LIMIT_FILE_SIZE" || code === "LIMIT_FILE_COUNT" ? 413 : 400;
7896
+ logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound upload rejected");
7897
+ res.status(status).json({ error: "upload_rejected", message: String(code ?? "malformed_multipart") });
7898
+ });
7899
+ },
7900
+ wrap(logger, async (req, res) => {
7901
+ let attachments;
7902
+ try {
7903
+ attachments = parseInbound(req);
7904
+ } catch (err) {
7905
+ logger.warn?.({ err, path: routePath }, "mailery: DMARC inbound payload unreadable");
7906
+ return res.status(400).json({ error: "bad_payload" });
7907
+ }
7908
+ const candidates = attachments.filter((a) => hasAllowedExtension(a.filename));
7909
+ if (candidates.length === 0) {
7910
+ logger.info?.(
7911
+ { path: routePath, attachments: attachments.length },
7912
+ "mailery: DMARC inbound message carried no report attachment \u2014 ignored"
7913
+ );
7914
+ return res.status(200).json({ ok: true, ingested: 0, ignored: attachments.length });
7915
+ }
7916
+ const ctx = mailer.getRunnerContext();
7917
+ const allowDomain = allowedDomains.size === 0 ? void 0 : (d) => allowedDomains.has(d.toLowerCase());
7918
+ const ingested = [];
7919
+ const rejected = [];
7920
+ for (const file of candidates) {
7921
+ try {
7922
+ const result = await ingestDmarcAttachment(ctx, file.buffer, file.filename, { allowDomain });
7923
+ ingested.push({ reportId: result.reportId, domain: result.domain, duplicate: result.duplicate });
7924
+ } catch (err) {
7925
+ const message = String(err?.message ?? err);
7926
+ rejected.push({ filename: file.filename, message });
7927
+ logger.warn?.(
7928
+ { path: routePath, filename: file.filename, err },
7929
+ "mailery: DMARC inbound attachment rejected"
7930
+ );
7931
+ }
7932
+ }
7933
+ if (ingested.length === 0) {
7934
+ return res.status(400).json({ error: "ingest_failed", rejected });
7935
+ }
7936
+ return res.status(200).json({ ok: true, ingested, rejected });
7937
+ })
7938
+ );
7939
+ return routePath;
7940
+ }
7941
+ function isAuthorized(req, secret) {
7942
+ const presented = extractPresentedSecret(req);
7943
+ if (presented === null) return false;
7944
+ return constantTimeEquals(presented, secret);
7945
+ }
7946
+ function extractPresentedSecret(req) {
7947
+ const header = req.headers.authorization;
7948
+ if (typeof header !== "string") return null;
7949
+ const [scheme, ...rest] = header.split(" ");
7950
+ const value = rest.join(" ").trim();
7951
+ if (!scheme || value === "") return null;
7952
+ switch (scheme.toLowerCase()) {
7953
+ case "basic": {
7954
+ let decoded;
7955
+ try {
7956
+ decoded = Buffer.from(value, "base64").toString("utf8");
7957
+ } catch {
7958
+ return null;
7959
+ }
7960
+ const sep = decoded.indexOf(":");
7961
+ return sep === -1 ? null : decoded.slice(sep + 1);
7962
+ }
7963
+ case "bearer":
7964
+ return value;
7965
+ default:
7966
+ return null;
7967
+ }
7968
+ }
7969
+ function constantTimeEquals(a, b) {
7970
+ const ha = createHash("sha256").update(a, "utf8").digest();
7971
+ const hb = createHash("sha256").update(b, "utf8").digest();
7972
+ return timingSafeEqual(ha, hb);
7973
+ }
7974
+ var sendgridInboundParser = (req) => {
7975
+ const files = req.files;
7976
+ if (!Array.isArray(files)) return [];
7977
+ return files.filter((f) => Boolean(f) && Buffer.isBuffer(f.buffer)).map((f) => ({ filename: safeFilename(f.originalname), buffer: f.buffer }));
7978
+ };
7979
+ function safeFilename(raw) {
7980
+ if (typeof raw !== "string") return "";
7981
+ return raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/[\\/]/g, "_").slice(0, 255);
7982
+ }
7983
+ function hasAllowedExtension(filename) {
7984
+ const lower = filename.toLowerCase();
7985
+ return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
7986
+ }
7987
+ function normalizeDomains(domains) {
7988
+ const out = /* @__PURE__ */ new Set();
7989
+ for (const d of domains) {
7990
+ if (typeof d !== "string") continue;
7991
+ const trimmed = d.trim().toLowerCase().replace(/^\.+/, "");
7992
+ if (trimmed) out.add(trimmed);
7993
+ }
7994
+ return out;
7995
+ }
7996
+ function deriveSenderDomains(mailer) {
7997
+ const out = [];
7998
+ const push = (domain) => {
7999
+ if (!domain) return;
8000
+ out.push(domain);
8001
+ const labels = domain.split(".");
8002
+ if (labels.length > 2) out.push(labels.slice(-2).join("."));
8003
+ };
8004
+ for (const domain of Object.keys(mailer.config.senderDomains ?? {})) push(domain.toLowerCase());
8005
+ push(emailDomain(mailer.config.fromDefaults?.email));
8006
+ push(emailDomain(mailer.config.transactionalFromDefaults?.email));
8007
+ push(emailDomain(mailer.config.senderAddress));
8008
+ return out;
8009
+ }
8010
+ function emailDomain(email) {
8011
+ if (typeof email !== "string") return void 0;
8012
+ const at = email.lastIndexOf("@");
8013
+ if (at === -1) return void 0;
8014
+ const domain = email.slice(at + 1).trim().toLowerCase();
8015
+ return domain || void 0;
8016
+ }
8017
+
8018
+ // src/server/api/public.ts
7405
8019
  var PIXEL = Buffer.from(
7406
8020
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
7407
8021
  "base64"
7408
8022
  );
7409
8023
  function createPublicRouter(mailer, opts = {}) {
7410
8024
  const router = Router();
7411
- const pendingUnsubsPath = opts.pendingUnsubsPath ?? "/tmp/mailery-pending-unsubs.jsonl";
8025
+ const logger = opts.logger ?? consoleRouteLogger;
8026
+ const pendingUnsubsPath = opts.pendingUnsubsPath ?? mailer.config.pendingUnsubsPath ?? null;
8027
+ if (opts.pendingUnsubsPath && !mailer.config.pendingUnsubsPath) {
8028
+ logger.warn?.(
8029
+ { path: opts.pendingUnsubsPath },
8030
+ "mailery: pendingUnsubsPath set on the public router but not in MailerConfig \u2014 the tick drain will not replay this journal"
8031
+ );
8032
+ }
8033
+ if (!pendingUnsubsPath) {
8034
+ logger.warn?.(
8035
+ {},
8036
+ "mailery: no pendingUnsubsPath configured \u2014 POST /unsub answers 503 when Mongo is unreachable instead of journaling the opt-out"
8037
+ );
8038
+ }
8039
+ const dmarcInboundPath = mountDmarcInbound(router, mailer, opts.dmarcInbound, logger);
8040
+ if (dmarcInboundPath) {
8041
+ logger.info?.(
8042
+ { path: dmarcInboundPath },
8043
+ "mailery: DMARC inbound route mounted (shared-secret auth; SendGrid Inbound Parse does not sign payloads)"
8044
+ );
8045
+ }
7412
8046
  router.use(
7413
8047
  "/webhooks",
7414
8048
  express.json({
@@ -7419,42 +8053,96 @@ function createPublicRouter(mailer, opts = {}) {
7419
8053
  })
7420
8054
  );
7421
8055
  router.use("/unsub", express.urlencoded({ extended: false }));
7422
- router.get("/open/:sendId.png", async (req, res) => {
8056
+ router.get("/open/:sendId.png", wrap(logger, async (req, res) => {
7423
8057
  res.setHeader("Content-Type", "image/png");
7424
8058
  res.setHeader("Content-Length", String(PIXEL.length));
7425
8059
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
7426
8060
  res.status(200).end(PIXEL);
7427
- const id = req.params.sendId;
8061
+ const parsed = splitOpenParam(req.params.sendId);
8062
+ if (!parsed) return;
8063
+ const { id, sig } = parsed;
7428
8064
  if (!ObjectId.isValid(id)) return;
7429
8065
  const sendId = new ObjectId(id);
8066
+ const verdict = checkTrackingSignature({
8067
+ scope: "open",
8068
+ params: { sendId: id },
8069
+ sig,
8070
+ secret: mailer.config.unsubscribeSecret,
8071
+ requireSigned: mailer.config.requireSignedTrackingUrls,
8072
+ logger,
8073
+ fields: { sendId: id }
8074
+ });
8075
+ if (verdict === "rejected") return;
7430
8076
  try {
7431
- const send = await mailer.collections.sends.findOne({ _id: sendId }, { projection: { openedAt: 1 } });
8077
+ const send = await mailer.collections.sends.findOne(
8078
+ { _id: sendId },
8079
+ { projection: { openedAt: 1, queuedAt: 1 } }
8080
+ );
7432
8081
  if (!send) return;
8082
+ if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
8083
+ logger.warn?.({ sendId: id }, "mailery: open ignored \u2014 tracking URL past trackingUrlLifetimeDays");
8084
+ return;
8085
+ }
8086
+ const now = /* @__PURE__ */ new Date();
7433
8087
  await mailer.collections.sends.updateOne(
7434
8088
  { _id: sendId },
7435
8089
  {
7436
8090
  $set: {
7437
- openedAt: send.openedAt ?? /* @__PURE__ */ new Date(),
8091
+ openedAt: send.openedAt ?? now,
7438
8092
  status: "delivered"
7439
8093
  },
7440
- $inc: { openCount: 1 }
8094
+ $inc: { openCount: 1 },
8095
+ // Per-open user agent, so `hasOpenedExcludingBots` has a signal to
8096
+ // filter on. Capped: an open pixel can be re-fetched indefinitely
8097
+ // (every time the recipient re-opens the mail) and an unbounded
8098
+ // array would eventually run the send document into the 16MB limit.
8099
+ $push: {
8100
+ opens: {
8101
+ $each: [{ openedAt: now, userAgent: requestUserAgent(req) }],
8102
+ $slice: -MAX_TRACKED_OPENS
8103
+ }
8104
+ }
7441
8105
  }
7442
8106
  );
7443
8107
  } catch (err) {
7444
- console.error("mailery: open pixel update failed", err);
8108
+ logger.error?.({ err, sendId: id }, "mailery: open pixel update failed");
7445
8109
  }
7446
- });
7447
- router.get("/click/:sendId/:linkId", async (req, res) => {
7448
- const { sendId: sendIdStr, linkId } = req.params;
8110
+ }));
8111
+ router.get("/click/:sendId/:linkId{/:sig}", wrap(logger, async (req, res) => {
8112
+ const { sendId: sendIdStr, linkId, sig } = req.params;
7449
8113
  if (!ObjectId.isValid(sendIdStr)) return res.status(400).end();
7450
8114
  const sendId = new ObjectId(sendIdStr);
8115
+ const verdict = checkTrackingSignature({
8116
+ scope: "click",
8117
+ params: { sendId: sendIdStr, linkId },
8118
+ sig,
8119
+ secret: mailer.config.unsubscribeSecret,
8120
+ requireSigned: mailer.config.requireSignedTrackingUrls,
8121
+ logger,
8122
+ fields: { sendId: sendIdStr, linkId }
8123
+ });
8124
+ if (verdict === "rejected") return res.status(404).end();
7451
8125
  const send = await mailer.collections.sends.findOne(
7452
8126
  { _id: sendId },
7453
- { projection: { links: 1, firstClickAt: 1 } }
8127
+ { projection: { links: 1, firstClickAt: 1, queuedAt: 1 } }
7454
8128
  );
7455
8129
  if (!send) return res.status(404).end();
8130
+ if (isTrackingExpired(send, mailer.config.trackingUrlLifetimeDays)) {
8131
+ logger.warn?.(
8132
+ { sendId: sendIdStr, linkId },
8133
+ "mailery: click rejected \u2014 tracking URL past trackingUrlLifetimeDays"
8134
+ );
8135
+ return res.status(404).end();
8136
+ }
7456
8137
  const link = (send.links ?? []).find((l) => l.linkId === linkId);
7457
8138
  if (!link) return res.status(404).end();
8139
+ if (!isSafeRedirectTarget(link.url)) {
8140
+ logger.warn?.(
8141
+ { sendId: sendIdStr, linkId, url: link.url },
8142
+ "mailery: click redirect blocked \u2014 disallowed URL scheme"
8143
+ );
8144
+ return res.status(400).type("html").send("<!doctype html><html><body><p>This link cannot be opened.</p></body></html>");
8145
+ }
7458
8146
  res.redirect(302, link.url);
7459
8147
  try {
7460
8148
  await mailer.collections.sends.updateOne(
@@ -7466,16 +8154,19 @@ function createPublicRouter(mailer, opts = {}) {
7466
8154
  clickedLinks: {
7467
8155
  url: link.url,
7468
8156
  linkId,
7469
- clickedAt: /* @__PURE__ */ new Date()
8157
+ clickedAt: /* @__PURE__ */ new Date(),
8158
+ // The bot filter has always read `clickedLinks[].userAgent`; until
8159
+ // now nothing ever wrote it, so every click scored as human.
8160
+ userAgent: requestUserAgent(req)
7470
8161
  }
7471
8162
  }
7472
8163
  }
7473
8164
  );
7474
8165
  } catch (err) {
7475
- console.error("mailery: click recording failed", err);
8166
+ logger.error?.({ err, sendId: sendIdStr, linkId }, "mailery: click recording failed");
7476
8167
  }
7477
- });
7478
- router.get("/unsub/:token", (req, res) => {
8168
+ }));
8169
+ router.get("/unsub/:token", wrap(logger, (req, res) => {
7479
8170
  const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
7480
8171
  if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
7481
8172
  res.status(200).type("html").send(`<!doctype html>
@@ -7490,32 +8181,55 @@ function createPublicRouter(mailer, opts = {}) {
7490
8181
  <button type="submit">Unsubscribe</button>
7491
8182
  </form>
7492
8183
  </body></html>`);
7493
- });
7494
- router.post("/unsub/:token", async (req, res) => {
8184
+ }));
8185
+ router.post("/unsub/:token", wrap(logger, async (req, res) => {
7495
8186
  const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
7496
8187
  if (!decoded) {
7497
8188
  res.status(200).end();
7498
8189
  return;
7499
8190
  }
7500
- res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
8191
+ const write = mailer.unsubscribe(decoded.email, {
8192
+ scope: decoded.scope,
8193
+ reason: "user_request",
8194
+ source: "one-click"
8195
+ });
8196
+ let dbError = null;
7501
8197
  try {
7502
- await mailer.unsubscribe(decoded.email, {
7503
- scope: decoded.scope,
7504
- reason: "user_request",
7505
- source: "one-click"
7506
- });
8198
+ await withTimeout(write, mailer.config.unsubscribeWriteTimeoutMs);
7507
8199
  } catch (err) {
8200
+ dbError = err ?? new Error("unsubscribe write failed");
8201
+ void write.catch(() => {
8202
+ });
8203
+ }
8204
+ if (dbError) {
8205
+ if (!pendingUnsubsPath) {
8206
+ logger.error?.(
8207
+ { err: dbError },
8208
+ "mailery: unsubscribe write failed and no pendingUnsubsPath is configured \u2014 answering 503"
8209
+ );
8210
+ return sendUnsubUnavailable(res);
8211
+ }
7508
8212
  try {
7509
- fs.appendFileSync(
7510
- pendingUnsubsPath,
7511
- JSON.stringify({ email: decoded.email, scope: decoded.scope, at: Date.now() }) + "\n"
8213
+ appendPendingUnsub(pendingUnsubsPath, {
8214
+ email: decoded.email,
8215
+ scope: decoded.scope,
8216
+ at: Date.now()
8217
+ });
8218
+ logger.warn?.(
8219
+ { err: dbError, path: pendingUnsubsPath },
8220
+ "mailery: unsubscribe journaled to disk \u2014 will be replayed by the tick drain"
7512
8221
  );
7513
8222
  } catch (diskErr) {
7514
- console.error("mailery: unsub disk fallback failed", { err, diskErr });
8223
+ logger.error?.(
8224
+ { err: dbError, diskErr, path: pendingUnsubsPath },
8225
+ "mailery: unsub disk fallback failed \u2014 answering 503"
8226
+ );
8227
+ return sendUnsubUnavailable(res);
7515
8228
  }
7516
8229
  }
7517
- });
7518
- router.get("/confirm-doi/:token", async (req, res) => {
8230
+ res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
8231
+ }));
8232
+ router.get("/confirm-doi/:token", wrap(logger, async (req, res) => {
7519
8233
  const token = req.params.token;
7520
8234
  const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
7521
8235
  if (!decoded) {
@@ -7543,10 +8257,10 @@ function createPublicRouter(mailer, opts = {}) {
7543
8257
  } catch {
7544
8258
  }
7545
8259
  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) => {
8260
+ }));
8261
+ router.post("/webhooks/:provider", wrap(logger, async (req, res) => {
7548
8262
  const providerName = req.params.provider;
7549
- const provider = mailer.providers[providerName];
8263
+ const provider = resolveProvider(mailer.providers, providerName);
7550
8264
  if (!provider) return res.status(404).end();
7551
8265
  const rawBody = req.rawBody;
7552
8266
  if (!rawBody) return res.status(400).end();
@@ -7577,7 +8291,10 @@ function createPublicRouter(mailer, opts = {}) {
7577
8291
  { upsert: true }
7578
8292
  );
7579
8293
  } catch (err) {
7580
- console.error("mailery: webhook dedupe insert failed", err);
8294
+ logger.error?.(
8295
+ { err, provider: providerName, providerEventId: evt.providerEventId },
8296
+ "mailery: webhook dedupe insert failed"
8297
+ );
7581
8298
  }
7582
8299
  }
7583
8300
  if (events.length > 0) {
@@ -7586,15 +8303,94 @@ function createPublicRouter(mailer, opts = {}) {
7586
8303
  } catch {
7587
8304
  }
7588
8305
  }
7589
- });
8306
+ }));
7590
8307
  return router;
7591
8308
  }
8309
+ var MAX_TRACKED_OPENS = 50;
8310
+ var MAX_UA_LENGTH = 256;
8311
+ function requestUserAgent(req) {
8312
+ const ua = req.headers["user-agent"];
8313
+ if (typeof ua !== "string" || ua.trim() === "") return null;
8314
+ return ua.slice(0, MAX_UA_LENGTH);
8315
+ }
8316
+ function splitOpenParam(raw) {
8317
+ if (typeof raw !== "string") return null;
8318
+ const parts = raw.split(".");
8319
+ if (parts.length === 1) return { id: parts[0] };
8320
+ if (parts.length === 2) return { id: parts[0], sig: parts[1] };
8321
+ return null;
8322
+ }
8323
+ function checkTrackingSignature(args) {
8324
+ const { sig, logger, fields, scope } = args;
8325
+ if (sig === void 0 || sig === "") {
8326
+ if (args.requireSigned) {
8327
+ logger.warn?.(
8328
+ { ...fields, scope },
8329
+ "mailery: unsigned tracking URL rejected (requireSignedTrackingUrls)"
8330
+ );
8331
+ return "rejected";
8332
+ }
8333
+ logger.info?.(
8334
+ { ...fields, scope },
8335
+ "mailery: unsigned tracking URL accepted \u2014 legacy grace mode"
8336
+ );
8337
+ return "legacy";
8338
+ }
8339
+ if (!verifyTrackingToken(sig, scope, args.params, args.secret)) {
8340
+ logger.warn?.({ ...fields, scope }, "mailery: tracking URL signature invalid");
8341
+ return "rejected";
8342
+ }
8343
+ return "signed";
8344
+ }
8345
+ function isTrackingExpired(send, lifetimeDays, now = /* @__PURE__ */ new Date()) {
8346
+ if (!lifetimeDays || lifetimeDays <= 0) return false;
8347
+ const queuedAt = send.queuedAt;
8348
+ if (!(queuedAt instanceof Date) || Number.isNaN(queuedAt.getTime())) return false;
8349
+ return now.getTime() - queuedAt.getTime() > lifetimeDays * 24 * 60 * 60 * 1e3;
8350
+ }
7592
8351
  function sendUnsubError(res, msg) {
7593
8352
  return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
7594
8353
  }
8354
+ function sendUnsubUnavailable(res) {
8355
+ return res.status(503).set("Retry-After", "60").type("html").send(
8356
+ "<!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>"
8357
+ );
8358
+ }
8359
+ function withTimeout(p, ms) {
8360
+ if (!Number.isFinite(ms) || ms <= 0) return p;
8361
+ return new Promise((resolve, reject) => {
8362
+ const timer = setTimeout(() => {
8363
+ reject(new Error(`timed out after ${ms}ms`));
8364
+ }, ms);
8365
+ if (typeof timer.unref === "function") timer.unref();
8366
+ p.then(
8367
+ (v) => {
8368
+ clearTimeout(timer);
8369
+ resolve(v);
8370
+ },
8371
+ (err) => {
8372
+ clearTimeout(timer);
8373
+ reject(err);
8374
+ }
8375
+ );
8376
+ });
8377
+ }
7595
8378
  function escapeHtml(s) {
7596
8379
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7597
8380
  }
8381
+ var ALLOWED_REDIRECT_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
8382
+ function isSafeRedirectTarget(raw) {
8383
+ if (typeof raw !== "string") return false;
8384
+ const trimmed = raw.trim();
8385
+ if (trimmed === "") return false;
8386
+ let parsed;
8387
+ try {
8388
+ parsed = new URL(trimmed);
8389
+ } catch {
8390
+ return false;
8391
+ }
8392
+ return ALLOWED_REDIRECT_PROTOCOLS.has(parsed.protocol);
8393
+ }
7598
8394
  function lowercaseHeaders(h) {
7599
8395
  const out = {};
7600
8396
  for (const k of Object.keys(h)) {
@@ -7763,8 +8559,8 @@ var DEDUPE_POLICIES = [
7763
8559
  ];
7764
8560
 
7765
8561
  // src/server/index.ts
7766
- var VERSION = "0.14.0" ;
8562
+ var VERSION = "0.15.0" ;
7767
8563
 
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 };
8564
+ export { DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, TRACKING_SIG_LENGTH, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, dispatchSend, drainPendingUnsubscribes, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, varsJsonSchema, verifyTrackingToken, verifyUnsubscribeToken };
7769
8565
  //# sourceMappingURL=index.js.map
7770
8566
  //# sourceMappingURL=index.js.map