mailery 0.13.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/admin/spa/index-B88z7X-U.css +1 -0
- package/dist/admin/spa/index-m4wK04gU.js +9 -0
- package/dist/admin/spa/index-m4wK04gU.js.map +1 -0
- package/dist/admin/spa/index.html +2 -2
- package/dist/admin/spa/template-editor-CKQLM0Me.js +156 -0
- package/dist/admin/spa/{template-editor-pGcCFnJ7.js.map → template-editor-CKQLM0Me.js.map} +1 -1
- package/dist/cli.cjs +5 -10
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +911 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +359 -13
- package/dist/index.d.ts +359 -13
- package/dist/index.js +904 -107
- package/dist/index.js.map +1 -1
- package/dist/{null-DDg3ojHe.d.cts → null-CnhsvKvy.d.cts} +136 -6
- package/dist/{null-DDg3ojHe.d.ts → null-CnhsvKvy.d.ts} +136 -6
- package/dist/testing.cjs +592 -191
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +591 -192
- package/dist/testing.js.map +1 -1
- package/package.json +15 -11
- package/dist/admin/spa/index-B6riKdpB.css +0 -1
- package/dist/admin/spa/index-l7m1PyMb.js +0 -41
- package/dist/admin/spa/index-l7m1PyMb.js.map +0 -1
- package/dist/admin/spa/template-editor-pGcCFnJ7.js +0 -502
package/dist/index.cjs
CHANGED
|
@@ -11,11 +11,12 @@ var mjml2html = require('mjml');
|
|
|
11
11
|
var dns = require('dns/promises');
|
|
12
12
|
var net = require('net');
|
|
13
13
|
var psl = require('psl');
|
|
14
|
+
var fastXmlParser = require('fast-xml-parser');
|
|
15
|
+
var fs = require('fs');
|
|
16
|
+
var path = require('path');
|
|
14
17
|
var express = require('express');
|
|
15
18
|
var multer = require('multer');
|
|
16
|
-
var path = require('path');
|
|
17
19
|
var url = require('url');
|
|
18
|
-
var fs = require('fs');
|
|
19
20
|
|
|
20
21
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
21
22
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -28,21 +29,20 @@ var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
|
|
|
28
29
|
var dns__default = /*#__PURE__*/_interopDefault(dns);
|
|
29
30
|
var net__default = /*#__PURE__*/_interopDefault(net);
|
|
30
31
|
var psl__default = /*#__PURE__*/_interopDefault(psl);
|
|
32
|
+
var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
33
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
31
34
|
var express__default = /*#__PURE__*/_interopDefault(express);
|
|
32
35
|
var multer__default = /*#__PURE__*/_interopDefault(multer);
|
|
33
|
-
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
34
|
-
var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
35
36
|
|
|
36
37
|
var __defProp = Object.defineProperty;
|
|
37
38
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
39
|
+
var __esm = (fn, res, err) => function __init() {
|
|
40
|
+
if (err) throw err[0];
|
|
41
|
+
try {
|
|
42
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw err = [e], e;
|
|
45
|
+
}
|
|
46
46
|
};
|
|
47
47
|
var __export = (target, all) => {
|
|
48
48
|
for (var name in all)
|
|
@@ -258,6 +258,31 @@ var init_mongo = __esm({
|
|
|
258
258
|
}
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
// src/server/providers/webhook-tolerance.ts
|
|
262
|
+
function resolveWebhookToleranceSeconds(option) {
|
|
263
|
+
if (option === false || option === 0) return 0;
|
|
264
|
+
if (option === void 0 || option === null) return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
265
|
+
if (typeof option !== "number" || !Number.isFinite(option) || option < 0) {
|
|
266
|
+
return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
267
|
+
}
|
|
268
|
+
return option;
|
|
269
|
+
}
|
|
270
|
+
function isWebhookTimestampFresh(rawTimestamp, toleranceSeconds, now = /* @__PURE__ */ new Date()) {
|
|
271
|
+
if (toleranceSeconds === 0) return true;
|
|
272
|
+
if (!rawTimestamp) return false;
|
|
273
|
+
if (!/^\d{1,15}$/.test(rawTimestamp)) return false;
|
|
274
|
+
const timestampMs = Number(rawTimestamp) * 1e3;
|
|
275
|
+
if (!Number.isFinite(timestampMs)) return false;
|
|
276
|
+
const deltaMs = Math.abs(now.getTime() - timestampMs);
|
|
277
|
+
return deltaMs <= toleranceSeconds * 1e3;
|
|
278
|
+
}
|
|
279
|
+
var DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
280
|
+
var init_webhook_tolerance = __esm({
|
|
281
|
+
"src/server/providers/webhook-tolerance.ts"() {
|
|
282
|
+
DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
261
286
|
// src/server/providers/sendgrid.ts
|
|
262
287
|
var sendgrid_exports = {};
|
|
263
288
|
__export(sendgrid_exports, {
|
|
@@ -304,6 +329,7 @@ function mapEventType(sgEvent) {
|
|
|
304
329
|
var SG_SIG_HEADER, SG_TS_HEADER; exports.SendGridProvider = void 0;
|
|
305
330
|
var init_sendgrid = __esm({
|
|
306
331
|
"src/server/providers/sendgrid.ts"() {
|
|
332
|
+
init_webhook_tolerance();
|
|
307
333
|
SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
|
|
308
334
|
SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
|
|
309
335
|
exports.SendGridProvider = class {
|
|
@@ -311,10 +337,13 @@ var init_sendgrid = __esm({
|
|
|
311
337
|
this.opts = opts;
|
|
312
338
|
sgMail__default.default.setApiKey(opts.apiKey);
|
|
313
339
|
this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
|
|
340
|
+
this.webhookToleranceSeconds = resolveWebhookToleranceSeconds(opts.webhookToleranceSeconds);
|
|
314
341
|
}
|
|
315
342
|
opts;
|
|
316
343
|
name = "sendgrid";
|
|
317
344
|
sendRatePerSecond;
|
|
345
|
+
/** Resolved replay window in seconds; `0` means the check is disabled. */
|
|
346
|
+
webhookToleranceSeconds;
|
|
318
347
|
async send(args) {
|
|
319
348
|
const msg = {
|
|
320
349
|
to: args.to,
|
|
@@ -354,10 +383,11 @@ var init_sendgrid = __esm({
|
|
|
354
383
|
try {
|
|
355
384
|
const verifier = crypto2__default.default.createVerify("sha256");
|
|
356
385
|
verifier.update(payload);
|
|
357
|
-
|
|
386
|
+
if (!verifier.verify(this.opts.webhookVerificationKey, sig, "base64")) return false;
|
|
358
387
|
} catch {
|
|
359
388
|
return false;
|
|
360
389
|
}
|
|
390
|
+
return isWebhookTimestampFresh(ts, this.webhookToleranceSeconds);
|
|
361
391
|
}
|
|
362
392
|
parseWebhookEvents(payload) {
|
|
363
393
|
if (!Array.isArray(payload)) return [];
|
|
@@ -487,6 +517,10 @@ var flowStepSchema = zod.z.lazy(
|
|
|
487
517
|
}),
|
|
488
518
|
zod.z.object({
|
|
489
519
|
type: zod.z.literal("webhook"),
|
|
520
|
+
// `url` is validated for shape only and is deliberately never templated —
|
|
521
|
+
// see the comment on handleWebhookStep in src/server/runner/step.ts, and
|
|
522
|
+
// INVARIANT 16. Rendering contact/event data into this field would turn an
|
|
523
|
+
// admin-authored flow into a server-side request forgery primitive.
|
|
490
524
|
url: zod.z.string().url(),
|
|
491
525
|
method: zod.z.enum(["POST", "PUT"]).optional(),
|
|
492
526
|
payload: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
|
|
@@ -580,6 +614,7 @@ var DEFAULTS = {
|
|
|
580
614
|
requireDoubleOptIn: false,
|
|
581
615
|
unsubscribeTokenLifetimeDays: 90,
|
|
582
616
|
transactionalRespectUnsubscribe: false,
|
|
617
|
+
unsubscribeWriteTimeoutMs: 5e3,
|
|
583
618
|
doiTemplateSlug: "doi-confirmation",
|
|
584
619
|
doiTokenLifetimeDays: 7,
|
|
585
620
|
broadcastConfirmationThreshold: 1e3,
|
|
@@ -596,7 +631,9 @@ var DEFAULTS = {
|
|
|
596
631
|
trackOpens: true,
|
|
597
632
|
trackClicks: true,
|
|
598
633
|
storeTrackingIp: false,
|
|
599
|
-
storeRenderedBody: false
|
|
634
|
+
storeRenderedBody: false,
|
|
635
|
+
requireSignedTrackingUrls: false,
|
|
636
|
+
trackingUrlLifetimeDays: 0
|
|
600
637
|
};
|
|
601
638
|
var CIRCUIT_BREAKER_DEFAULTS = {
|
|
602
639
|
hardBounceRatePctTrip: 2,
|
|
@@ -613,6 +650,7 @@ function resolveConfig(c) {
|
|
|
613
650
|
requireDoubleOptIn: c.requireDoubleOptIn ?? DEFAULTS.requireDoubleOptIn,
|
|
614
651
|
unsubscribeTokenLifetimeDays: c.unsubscribeTokenLifetimeDays ?? DEFAULTS.unsubscribeTokenLifetimeDays,
|
|
615
652
|
transactionalRespectUnsubscribe: c.transactionalRespectUnsubscribe ?? DEFAULTS.transactionalRespectUnsubscribe,
|
|
653
|
+
unsubscribeWriteTimeoutMs: c.unsubscribeWriteTimeoutMs ?? DEFAULTS.unsubscribeWriteTimeoutMs,
|
|
616
654
|
doiTemplateSlug: c.doiTemplateSlug ?? DEFAULTS.doiTemplateSlug,
|
|
617
655
|
doiTokenLifetimeDays: c.doiTokenLifetimeDays ?? DEFAULTS.doiTokenLifetimeDays,
|
|
618
656
|
broadcastConfirmationThreshold: c.broadcastConfirmationThreshold ?? DEFAULTS.broadcastConfirmationThreshold,
|
|
@@ -630,6 +668,8 @@ function resolveConfig(c) {
|
|
|
630
668
|
trackClicks: c.trackClicks ?? DEFAULTS.trackClicks,
|
|
631
669
|
storeTrackingIp: c.storeTrackingIp ?? DEFAULTS.storeTrackingIp,
|
|
632
670
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
671
|
+
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
672
|
+
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
633
673
|
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
634
674
|
};
|
|
635
675
|
}
|
|
@@ -832,6 +872,22 @@ function isoDay(d) {
|
|
|
832
872
|
function randomId() {
|
|
833
873
|
return crypto2__default.default.randomBytes(8).toString("hex");
|
|
834
874
|
}
|
|
875
|
+
|
|
876
|
+
// src/server/provider-lookup.ts
|
|
877
|
+
var PROVIDER_METHODS = ["send", "verifyWebhook", "parseWebhookEvents"];
|
|
878
|
+
function isMailProvider(candidate) {
|
|
879
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
880
|
+
const p = candidate;
|
|
881
|
+
return PROVIDER_METHODS.every((m) => typeof p[m] === "function");
|
|
882
|
+
}
|
|
883
|
+
function resolveProvider(providers, name) {
|
|
884
|
+
if (typeof name !== "string" || !Object.hasOwn(providers, name)) return null;
|
|
885
|
+
const candidate = providers[name];
|
|
886
|
+
return isMailProvider(candidate) ? candidate : null;
|
|
887
|
+
}
|
|
888
|
+
function registeredProviderNames(providers) {
|
|
889
|
+
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
890
|
+
}
|
|
835
891
|
function signUnsubscribeToken(payload, secret) {
|
|
836
892
|
const body = JSON.stringify({
|
|
837
893
|
e: payload.email.toLowerCase(),
|
|
@@ -870,6 +926,28 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
|
|
|
870
926
|
expiresAt: new Date(body.x)
|
|
871
927
|
};
|
|
872
928
|
}
|
|
929
|
+
var TRACKING_SCOPES = ["open", "click"];
|
|
930
|
+
var TRACKING_SIG_LENGTH = 12;
|
|
931
|
+
function trackingMessage(scope, params) {
|
|
932
|
+
const tail = params.linkId === void 0 ? "" : `:${params.linkId}`;
|
|
933
|
+
return `mailery.track.v1:${scope}:${params.sendId}${tail}`;
|
|
934
|
+
}
|
|
935
|
+
function signTrackingToken(scope, params, secret) {
|
|
936
|
+
if (!TRACKING_SCOPES.includes(scope)) {
|
|
937
|
+
throw new Error(`signTrackingToken: unknown scope ${String(scope)}`);
|
|
938
|
+
}
|
|
939
|
+
const mac = crypto2__default.default.createHmac("sha256", secret).update(trackingMessage(scope, params)).digest();
|
|
940
|
+
return b64url(mac).slice(0, TRACKING_SIG_LENGTH);
|
|
941
|
+
}
|
|
942
|
+
function verifyTrackingToken(token, scope, params, secret) {
|
|
943
|
+
if (typeof token !== "string") return false;
|
|
944
|
+
if (!TRACKING_SCOPES.includes(scope)) return false;
|
|
945
|
+
if (token.length !== TRACKING_SIG_LENGTH) return false;
|
|
946
|
+
const expected = Buffer.from(signTrackingToken(scope, params, secret), "utf8");
|
|
947
|
+
const actual = Buffer.from(token, "utf8");
|
|
948
|
+
if (expected.length !== actual.length) return false;
|
|
949
|
+
return crypto2__default.default.timingSafeEqual(expected, actual);
|
|
950
|
+
}
|
|
873
951
|
function b64url(buf) {
|
|
874
952
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
875
953
|
}
|
|
@@ -913,6 +991,39 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
913
991
|
if (body.x < now.getTime()) return null;
|
|
914
992
|
return { externalId: body.i, expiresAt: new Date(body.x) };
|
|
915
993
|
}
|
|
994
|
+
|
|
995
|
+
// src/server/unsubscribe.ts
|
|
996
|
+
async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
|
|
997
|
+
const normalized = input.email;
|
|
998
|
+
await collections.suppressions.updateOne(
|
|
999
|
+
{ email: normalized, scope: input.scope },
|
|
1000
|
+
{
|
|
1001
|
+
$setOnInsert: {
|
|
1002
|
+
email: normalized,
|
|
1003
|
+
emailHash: sha256Hex(normalized),
|
|
1004
|
+
scope: input.scope,
|
|
1005
|
+
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
1006
|
+
reason: "unsubscribed",
|
|
1007
|
+
source: input.source,
|
|
1008
|
+
notes: input.notes ?? null,
|
|
1009
|
+
addedAt: now,
|
|
1010
|
+
expiresAt: null
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
{ upsert: true }
|
|
1014
|
+
);
|
|
1015
|
+
await collections.subscriptions.updateOne(
|
|
1016
|
+
{ emailAtSubscribe: normalized },
|
|
1017
|
+
{
|
|
1018
|
+
$set: {
|
|
1019
|
+
status: "unsubscribed",
|
|
1020
|
+
unsubscribedAt: now,
|
|
1021
|
+
unsubscribeReason: input.reason,
|
|
1022
|
+
updatedAt: now
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
916
1027
|
var QUEUE_NAMES = {
|
|
917
1028
|
tick: "mailer-tick",
|
|
918
1029
|
advance: "mailer-advance",
|
|
@@ -1745,7 +1856,7 @@ async function suppressOnce(ctx, email, reason, scope) {
|
|
|
1745
1856
|
}
|
|
1746
1857
|
|
|
1747
1858
|
// src/server/runner/predicate.ts
|
|
1748
|
-
var
|
|
1859
|
+
var DEFAULT_BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1749
1860
|
async function evaluatePredicate(predicate, ctx) {
|
|
1750
1861
|
const p = predicate;
|
|
1751
1862
|
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
@@ -1813,17 +1924,35 @@ async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
|
1813
1924
|
return await ctx.collections.sends.countDocuments(filter);
|
|
1814
1925
|
}
|
|
1815
1926
|
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1927
|
+
const botRe = ctx.botFilter?.userAgentPattern ?? DEFAULT_BOT_UA_RE;
|
|
1928
|
+
const minOpenDelayMs = ctx.botFilter?.minOpenDelayMs ?? 0;
|
|
1816
1929
|
let n = 0;
|
|
1817
1930
|
for (const s of docs) {
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
}
|
|
1822
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1823
|
-
if (hasHumanClick) n++;
|
|
1931
|
+
const doc = s;
|
|
1932
|
+
const human = kind === "opened" ? hasHumanOpen(doc, botRe, minOpenDelayMs) : (doc.clickedLinks ?? []).some((c) => !isBotUserAgent(c.userAgent, botRe));
|
|
1933
|
+
if (human) n++;
|
|
1824
1934
|
}
|
|
1825
1935
|
return n;
|
|
1826
1936
|
}
|
|
1937
|
+
function isBotUserAgent(ua, botRe) {
|
|
1938
|
+
if (typeof ua !== "string" || ua.trim() === "") return false;
|
|
1939
|
+
return botRe.test(ua);
|
|
1940
|
+
}
|
|
1941
|
+
function hasHumanOpen(send, botRe, minOpenDelayMs) {
|
|
1942
|
+
const opens = send.opens && send.opens.length > 0 ? send.opens : send.openedAt ? [{ openedAt: send.openedAt, userAgent: null }] : [];
|
|
1943
|
+
return opens.some((o) => {
|
|
1944
|
+
if (isBotUserAgent(o.userAgent, botRe)) return false;
|
|
1945
|
+
if (isPrefetchOpen(o.openedAt, send.queuedAt, minOpenDelayMs)) return false;
|
|
1946
|
+
return true;
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
function isPrefetchOpen(openedAt, queuedAt, minOpenDelayMs) {
|
|
1950
|
+
if (!minOpenDelayMs || minOpenDelayMs <= 0) return false;
|
|
1951
|
+
if (!(openedAt instanceof Date) || !(queuedAt instanceof Date)) return false;
|
|
1952
|
+
const delta = openedAt.getTime() - queuedAt.getTime();
|
|
1953
|
+
if (!Number.isFinite(delta) || delta < 0) return false;
|
|
1954
|
+
return delta < minOpenDelayMs;
|
|
1955
|
+
}
|
|
1827
1956
|
function effectiveLowerBound(ctx, opts) {
|
|
1828
1957
|
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1829
1958
|
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
@@ -1951,6 +2080,7 @@ function derivePlaintext(html) {
|
|
|
1951
2080
|
]
|
|
1952
2081
|
}).trim();
|
|
1953
2082
|
}
|
|
2083
|
+
var PLAIN_TEXT = { noEscape: true };
|
|
1954
2084
|
async function renderTemplate(template, ctx, opts = {}) {
|
|
1955
2085
|
const hb = makeHandlebars(opts.helpers);
|
|
1956
2086
|
const subject = hb.compile(template.subject)(ctx);
|
|
@@ -1962,7 +2092,7 @@ async function renderTemplate(template, ctx, opts = {}) {
|
|
|
1962
2092
|
const compiled = await compileTemplate(template.body.mjml);
|
|
1963
2093
|
html = hb.compile(compiled.html)(ctx);
|
|
1964
2094
|
}
|
|
1965
|
-
const plainText = template.body.plainText ? hb.compile(template.body.plainText)(ctx) : derivePlaintext(html);
|
|
2095
|
+
const plainText = template.body.plainText ? hb.compile(template.body.plainText, PLAIN_TEXT)(ctx) : derivePlaintext(html);
|
|
1966
2096
|
return {
|
|
1967
2097
|
subject,
|
|
1968
2098
|
preheader,
|
|
@@ -1988,12 +2118,14 @@ function applyTracking(html, opts) {
|
|
|
1988
2118
|
seen.set(url, linkId);
|
|
1989
2119
|
links.push({ linkId, url });
|
|
1990
2120
|
}
|
|
1991
|
-
const
|
|
2121
|
+
const sig = opts.signingSecret ? `/${signTrackingToken("click", { sendId: opts.sendId, linkId }, opts.signingSecret)}` : "";
|
|
2122
|
+
const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}${sig}`;
|
|
1992
2123
|
return `<a${pre}href="${newHref}"${post}>`;
|
|
1993
2124
|
});
|
|
1994
2125
|
}
|
|
1995
2126
|
if (opts.trackOpens) {
|
|
1996
|
-
const
|
|
2127
|
+
const sig = opts.signingSecret ? `.${signTrackingToken("open", { sendId: opts.sendId }, opts.signingSecret)}` : "";
|
|
2128
|
+
const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}${sig}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
|
|
1997
2129
|
if (/<\/body>/i.test(out)) {
|
|
1998
2130
|
out = out.replace(/<\/body>/i, `${pixel}</body>`);
|
|
1999
2131
|
} else {
|
|
@@ -2004,14 +2136,22 @@ function applyTracking(html, opts) {
|
|
|
2004
2136
|
}
|
|
2005
2137
|
function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
2006
2138
|
if (!url) return true;
|
|
2007
|
-
if (url.startsWith("mailto:")) return true;
|
|
2008
|
-
if (url.startsWith("tel:")) return true;
|
|
2009
2139
|
if (url.startsWith("#")) return true;
|
|
2010
2140
|
if (url.startsWith("{{") || url.startsWith("{")) return true;
|
|
2011
2141
|
if (preserve.has(url.trim())) return true;
|
|
2012
2142
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
2143
|
+
if (!isHttpUrl(url)) return true;
|
|
2013
2144
|
return false;
|
|
2014
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
|
+
}
|
|
2015
2155
|
var NAMED_ENTITIES = {
|
|
2016
2156
|
amp: "&",
|
|
2017
2157
|
lt: "<",
|
|
@@ -2232,7 +2372,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2232
2372
|
publicUrl: ctx.config.publicUrl,
|
|
2233
2373
|
trackOpens: template.trackOpens ?? ctx.config.trackOpens,
|
|
2234
2374
|
trackClicks: template.trackClicks ?? ctx.config.trackClicks,
|
|
2235
|
-
preserveUrls: [renderCtx.unsubscribeUrl]
|
|
2375
|
+
preserveUrls: [renderCtx.unsubscribeUrl],
|
|
2376
|
+
signingSecret: ctx.config.unsubscribeSecret
|
|
2236
2377
|
});
|
|
2237
2378
|
await ctx.collections.sends.updateOne(
|
|
2238
2379
|
{ _id: send._id },
|
|
@@ -2250,9 +2391,14 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2250
2391
|
}
|
|
2251
2392
|
}
|
|
2252
2393
|
);
|
|
2253
|
-
const provider = ctx.providers
|
|
2394
|
+
const provider = resolveProvider(ctx.providers, send.provider);
|
|
2254
2395
|
if (!provider) {
|
|
2255
|
-
|
|
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
|
+
);
|
|
2256
2402
|
return;
|
|
2257
2403
|
}
|
|
2258
2404
|
const headers = send.kind === "marketing" ? {
|
|
@@ -2451,7 +2597,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2451
2597
|
}
|
|
2452
2598
|
async function handleCondition(run, step, contact, ctx) {
|
|
2453
2599
|
if (!contact) return;
|
|
2454
|
-
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 });
|
|
2455
2601
|
if (result) {
|
|
2456
2602
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
2457
2603
|
return;
|
|
@@ -2464,7 +2610,7 @@ async function handleCondition(run, step, contact, ctx) {
|
|
|
2464
2610
|
}
|
|
2465
2611
|
async function handleBranch(run, step, contact, ctx) {
|
|
2466
2612
|
if (!contact) return;
|
|
2467
|
-
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 });
|
|
2468
2614
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
2469
2615
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2470
2616
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -3608,8 +3754,6 @@ function nthSundayOfMonth(year, month0, n) {
|
|
|
3608
3754
|
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
3609
3755
|
return firstSunday + (n - 1) * 7;
|
|
3610
3756
|
}
|
|
3611
|
-
|
|
3612
|
-
// src/server/runner/dmarc.ts
|
|
3613
3757
|
var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
|
|
3614
3758
|
function assertSafeZipEntryName(rawName) {
|
|
3615
3759
|
if (typeof rawName !== "string" || rawName.length === 0) {
|
|
@@ -3683,8 +3827,7 @@ async function extractDmarcXmls(buffer, filename) {
|
|
|
3683
3827
|
return [buffer.toString("utf8")];
|
|
3684
3828
|
}
|
|
3685
3829
|
function parseDmarcReport(xml) {
|
|
3686
|
-
const
|
|
3687
|
-
const parser = new XMLParser({
|
|
3830
|
+
const parser = new fastXmlParser.XMLParser({
|
|
3688
3831
|
ignoreAttributes: false,
|
|
3689
3832
|
parseAttributeValue: false,
|
|
3690
3833
|
parseTagValue: false,
|
|
@@ -3774,7 +3917,7 @@ function toArray(v) {
|
|
|
3774
3917
|
if (v === void 0 || v === null) return [];
|
|
3775
3918
|
return Array.isArray(v) ? v : [v];
|
|
3776
3919
|
}
|
|
3777
|
-
async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
3920
|
+
async function ingestDmarcAttachment(ctx, buffer, filename, opts = {}) {
|
|
3778
3921
|
const xmls = await extractDmarcXmls(buffer, filename);
|
|
3779
3922
|
if (xmls.length === 0) throw new Error("attachment contained no XML payload");
|
|
3780
3923
|
const results = [];
|
|
@@ -3782,6 +3925,9 @@ async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
|
3782
3925
|
for (const xml of xmls) {
|
|
3783
3926
|
try {
|
|
3784
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
|
+
}
|
|
3785
3931
|
results.push(await ingestParsedDmarcReport(ctx, parsed));
|
|
3786
3932
|
} catch (err) {
|
|
3787
3933
|
if (!firstError) firstError = err;
|
|
@@ -3892,6 +4038,260 @@ function suggestPolicyProgression(input) {
|
|
|
3892
4038
|
}
|
|
3893
4039
|
return null;
|
|
3894
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
|
+
}
|
|
3895
4295
|
|
|
3896
4296
|
// src/server/runner/tick.ts
|
|
3897
4297
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
@@ -3933,6 +4333,9 @@ async function runTick(ctx) {
|
|
|
3933
4333
|
await drainOutbox(ctx).catch((err) => {
|
|
3934
4334
|
console.error("mailery: outbox drain failed", err);
|
|
3935
4335
|
});
|
|
4336
|
+
await drainPendingUnsubscribes(ctx).catch((err) => {
|
|
4337
|
+
console.error("mailery: pending-unsubscribe drain failed", err);
|
|
4338
|
+
});
|
|
3936
4339
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3937
4340
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3938
4341
|
});
|
|
@@ -4065,6 +4468,10 @@ var Mailer = class _Mailer {
|
|
|
4065
4468
|
* MAILER_FROM_NAME / MAILER_FROM_EMAIL
|
|
4066
4469
|
* MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
|
|
4067
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.
|
|
4068
4475
|
* MAILER_HOST_USERS_COLLECTION (default 'users')
|
|
4069
4476
|
* MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
|
|
4070
4477
|
* MAILER_HOST_USERS_ID_FIELD (default '_id')
|
|
@@ -4099,6 +4506,10 @@ var Mailer = class _Mailer {
|
|
|
4099
4506
|
providers.sendgrid = new SendGridProvider2({
|
|
4100
4507
|
apiKey: env.MAILER_SENDGRID_API_KEY,
|
|
4101
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,
|
|
4102
4513
|
sandbox: env.NODE_ENV !== "production"
|
|
4103
4514
|
});
|
|
4104
4515
|
}
|
|
@@ -4126,8 +4537,15 @@ var Mailer = class _Mailer {
|
|
|
4126
4537
|
}
|
|
4127
4538
|
static async init(input) {
|
|
4128
4539
|
const config = resolveConfig(input);
|
|
4129
|
-
if (!config.providers
|
|
4130
|
-
throw new Error(
|
|
4540
|
+
if (!resolveProvider(config.providers, config.defaultProvider)) {
|
|
4541
|
+
throw new Error(
|
|
4542
|
+
`defaultProvider "${config.defaultProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4543
|
+
);
|
|
4544
|
+
}
|
|
4545
|
+
if (config.defaultTransactionalProvider != null && !resolveProvider(config.providers, config.defaultTransactionalProvider)) {
|
|
4546
|
+
throw new Error(
|
|
4547
|
+
`defaultTransactionalProvider "${config.defaultTransactionalProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4548
|
+
);
|
|
4131
4549
|
}
|
|
4132
4550
|
if (config.varsAdapter) {
|
|
4133
4551
|
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
@@ -4269,38 +4687,14 @@ var Mailer = class _Mailer {
|
|
|
4269
4687
|
}
|
|
4270
4688
|
}
|
|
4271
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
|
+
*/
|
|
4272
4695
|
async unsubscribe(email, opts) {
|
|
4273
4696
|
const parsed = unsubscribeInputSchema.parse({ email, ...opts });
|
|
4274
|
-
|
|
4275
|
-
const now = /* @__PURE__ */ new Date();
|
|
4276
|
-
await this.collections.suppressions.updateOne(
|
|
4277
|
-
{ email: normalized, scope: parsed.scope },
|
|
4278
|
-
{
|
|
4279
|
-
$setOnInsert: {
|
|
4280
|
-
email: normalized,
|
|
4281
|
-
emailHash: sha256Hex(normalized),
|
|
4282
|
-
scope: parsed.scope,
|
|
4283
|
-
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
4284
|
-
reason: "unsubscribed",
|
|
4285
|
-
source: parsed.source,
|
|
4286
|
-
notes: parsed.notes ?? null,
|
|
4287
|
-
addedAt: now,
|
|
4288
|
-
expiresAt: null
|
|
4289
|
-
}
|
|
4290
|
-
},
|
|
4291
|
-
{ upsert: true }
|
|
4292
|
-
);
|
|
4293
|
-
await this.collections.subscriptions.updateOne(
|
|
4294
|
-
{ emailAtSubscribe: normalized },
|
|
4295
|
-
{
|
|
4296
|
-
$set: {
|
|
4297
|
-
status: "unsubscribed",
|
|
4298
|
-
unsubscribedAt: now,
|
|
4299
|
-
unsubscribeReason: parsed.reason,
|
|
4300
|
-
updatedAt: now
|
|
4301
|
-
}
|
|
4302
|
-
}
|
|
4303
|
-
);
|
|
4697
|
+
await applyUnsubscribe(this.collections, parsed);
|
|
4304
4698
|
}
|
|
4305
4699
|
async suppress(email, opts) {
|
|
4306
4700
|
const parsed = suppressInputSchema.parse({ email, ...opts });
|
|
@@ -4636,8 +5030,20 @@ var NullProvider = class {
|
|
|
4636
5030
|
status: "accepted"
|
|
4637
5031
|
};
|
|
4638
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
|
+
*/
|
|
4639
5045
|
async verifyWebhook() {
|
|
4640
|
-
return
|
|
5046
|
+
return false;
|
|
4641
5047
|
}
|
|
4642
5048
|
parseWebhookEvents() {
|
|
4643
5049
|
return [];
|
|
@@ -6779,11 +7185,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6779
7185
|
if (cached) {
|
|
6780
7186
|
return res.json({ cached: true, status: "ready", score: cached });
|
|
6781
7187
|
}
|
|
6782
|
-
const
|
|
6783
|
-
const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
|
|
7188
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
6784
7189
|
if (!provider) {
|
|
6785
|
-
return res.status(
|
|
7190
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
6786
7191
|
}
|
|
7192
|
+
const { checkId, emailAddress } = await client.provisionCheck();
|
|
6787
7193
|
try {
|
|
6788
7194
|
await provider.send({
|
|
6789
7195
|
to: emailAddress,
|
|
@@ -7132,7 +7538,10 @@ function apiRouter(mailer, opts = {}) {
|
|
|
7132
7538
|
trackOpens: false,
|
|
7133
7539
|
trackClicks: false
|
|
7134
7540
|
});
|
|
7135
|
-
const provider = mailer.providers
|
|
7541
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
7542
|
+
if (!provider) {
|
|
7543
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
7544
|
+
}
|
|
7136
7545
|
const result = await provider.send({
|
|
7137
7546
|
to,
|
|
7138
7547
|
fromName: rendered.fromName,
|
|
@@ -7284,6 +7693,18 @@ function asyncHandler(fn) {
|
|
|
7284
7693
|
fn(req, res, next).catch(next);
|
|
7285
7694
|
};
|
|
7286
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
|
+
}
|
|
7287
7708
|
async function collectQueueCounts(mailer) {
|
|
7288
7709
|
const qs = Object.values(mailer.queues);
|
|
7289
7710
|
const sum = async (key) => {
|
|
@@ -7419,13 +7840,227 @@ async function computeBroadcastStats(mailer, idFilter) {
|
|
|
7419
7840
|
}
|
|
7420
7841
|
return out;
|
|
7421
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
|
|
7422
8037
|
var PIXEL = Buffer.from(
|
|
7423
8038
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
7424
8039
|
"base64"
|
|
7425
8040
|
);
|
|
7426
8041
|
function createPublicRouter(mailer, opts = {}) {
|
|
7427
8042
|
const router = express.Router();
|
|
7428
|
-
const
|
|
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
|
+
}
|
|
7429
8064
|
router.use(
|
|
7430
8065
|
"/webhooks",
|
|
7431
8066
|
express__default.default.json({
|
|
@@ -7436,42 +8071,96 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7436
8071
|
})
|
|
7437
8072
|
);
|
|
7438
8073
|
router.use("/unsub", express__default.default.urlencoded({ extended: false }));
|
|
7439
|
-
router.get("/open/:sendId.png", async (req, res) => {
|
|
8074
|
+
router.get("/open/:sendId.png", wrap(logger, async (req, res) => {
|
|
7440
8075
|
res.setHeader("Content-Type", "image/png");
|
|
7441
8076
|
res.setHeader("Content-Length", String(PIXEL.length));
|
|
7442
8077
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
7443
8078
|
res.status(200).end(PIXEL);
|
|
7444
|
-
const
|
|
8079
|
+
const parsed = splitOpenParam(req.params.sendId);
|
|
8080
|
+
if (!parsed) return;
|
|
8081
|
+
const { id, sig } = parsed;
|
|
7445
8082
|
if (!mongodb.ObjectId.isValid(id)) return;
|
|
7446
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;
|
|
7447
8094
|
try {
|
|
7448
|
-
const send = await mailer.collections.sends.findOne(
|
|
8095
|
+
const send = await mailer.collections.sends.findOne(
|
|
8096
|
+
{ _id: sendId },
|
|
8097
|
+
{ projection: { openedAt: 1, queuedAt: 1 } }
|
|
8098
|
+
);
|
|
7449
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();
|
|
7450
8105
|
await mailer.collections.sends.updateOne(
|
|
7451
8106
|
{ _id: sendId },
|
|
7452
8107
|
{
|
|
7453
8108
|
$set: {
|
|
7454
|
-
openedAt: send.openedAt ??
|
|
8109
|
+
openedAt: send.openedAt ?? now,
|
|
7455
8110
|
status: "delivered"
|
|
7456
8111
|
},
|
|
7457
|
-
$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
|
+
}
|
|
7458
8123
|
}
|
|
7459
8124
|
);
|
|
7460
8125
|
} catch (err) {
|
|
7461
|
-
|
|
8126
|
+
logger.error?.({ err, sendId: id }, "mailery: open pixel update failed");
|
|
7462
8127
|
}
|
|
7463
|
-
});
|
|
7464
|
-
router.get("/click/:sendId/:linkId", async (req, res) => {
|
|
7465
|
-
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;
|
|
7466
8131
|
if (!mongodb.ObjectId.isValid(sendIdStr)) return res.status(400).end();
|
|
7467
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();
|
|
7468
8143
|
const send = await mailer.collections.sends.findOne(
|
|
7469
8144
|
{ _id: sendId },
|
|
7470
|
-
{ projection: { links: 1, firstClickAt: 1 } }
|
|
8145
|
+
{ projection: { links: 1, firstClickAt: 1, queuedAt: 1 } }
|
|
7471
8146
|
);
|
|
7472
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
|
+
}
|
|
7473
8155
|
const link = (send.links ?? []).find((l) => l.linkId === linkId);
|
|
7474
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
|
+
}
|
|
7475
8164
|
res.redirect(302, link.url);
|
|
7476
8165
|
try {
|
|
7477
8166
|
await mailer.collections.sends.updateOne(
|
|
@@ -7483,16 +8172,19 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7483
8172
|
clickedLinks: {
|
|
7484
8173
|
url: link.url,
|
|
7485
8174
|
linkId,
|
|
7486
|
-
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)
|
|
7487
8179
|
}
|
|
7488
8180
|
}
|
|
7489
8181
|
}
|
|
7490
8182
|
);
|
|
7491
8183
|
} catch (err) {
|
|
7492
|
-
|
|
8184
|
+
logger.error?.({ err, sendId: sendIdStr, linkId }, "mailery: click recording failed");
|
|
7493
8185
|
}
|
|
7494
|
-
});
|
|
7495
|
-
router.get("/unsub/:token", (req, res) => {
|
|
8186
|
+
}));
|
|
8187
|
+
router.get("/unsub/:token", wrap(logger, (req, res) => {
|
|
7496
8188
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7497
8189
|
if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
|
|
7498
8190
|
res.status(200).type("html").send(`<!doctype html>
|
|
@@ -7507,32 +8199,55 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7507
8199
|
<button type="submit">Unsubscribe</button>
|
|
7508
8200
|
</form>
|
|
7509
8201
|
</body></html>`);
|
|
7510
|
-
});
|
|
7511
|
-
router.post("/unsub/:token", async (req, res) => {
|
|
8202
|
+
}));
|
|
8203
|
+
router.post("/unsub/:token", wrap(logger, async (req, res) => {
|
|
7512
8204
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7513
8205
|
if (!decoded) {
|
|
7514
8206
|
res.status(200).end();
|
|
7515
8207
|
return;
|
|
7516
8208
|
}
|
|
7517
|
-
|
|
8209
|
+
const write = mailer.unsubscribe(decoded.email, {
|
|
8210
|
+
scope: decoded.scope,
|
|
8211
|
+
reason: "user_request",
|
|
8212
|
+
source: "one-click"
|
|
8213
|
+
});
|
|
8214
|
+
let dbError = null;
|
|
7518
8215
|
try {
|
|
7519
|
-
await mailer.
|
|
7520
|
-
scope: decoded.scope,
|
|
7521
|
-
reason: "user_request",
|
|
7522
|
-
source: "one-click"
|
|
7523
|
-
});
|
|
8216
|
+
await withTimeout(write, mailer.config.unsubscribeWriteTimeoutMs);
|
|
7524
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
|
+
}
|
|
7525
8230
|
try {
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
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"
|
|
7529
8239
|
);
|
|
7530
8240
|
} catch (diskErr) {
|
|
7531
|
-
|
|
8241
|
+
logger.error?.(
|
|
8242
|
+
{ err: dbError, diskErr, path: pendingUnsubsPath },
|
|
8243
|
+
"mailery: unsub disk fallback failed \u2014 answering 503"
|
|
8244
|
+
);
|
|
8245
|
+
return sendUnsubUnavailable(res);
|
|
7532
8246
|
}
|
|
7533
8247
|
}
|
|
7534
|
-
|
|
7535
|
-
|
|
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) => {
|
|
7536
8251
|
const token = req.params.token;
|
|
7537
8252
|
const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
|
|
7538
8253
|
if (!decoded) {
|
|
@@ -7560,10 +8275,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7560
8275
|
} catch {
|
|
7561
8276
|
}
|
|
7562
8277
|
return res.status(200).type("html").send("<!doctype html><html><body><p>Thanks \u2014 you're subscribed.</p></body></html>");
|
|
7563
|
-
});
|
|
7564
|
-
router.post("/webhooks/:provider", async (req, res) => {
|
|
8278
|
+
}));
|
|
8279
|
+
router.post("/webhooks/:provider", wrap(logger, async (req, res) => {
|
|
7565
8280
|
const providerName = req.params.provider;
|
|
7566
|
-
const provider = mailer.providers
|
|
8281
|
+
const provider = resolveProvider(mailer.providers, providerName);
|
|
7567
8282
|
if (!provider) return res.status(404).end();
|
|
7568
8283
|
const rawBody = req.rawBody;
|
|
7569
8284
|
if (!rawBody) return res.status(400).end();
|
|
@@ -7594,7 +8309,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7594
8309
|
{ upsert: true }
|
|
7595
8310
|
);
|
|
7596
8311
|
} catch (err) {
|
|
7597
|
-
|
|
8312
|
+
logger.error?.(
|
|
8313
|
+
{ err, provider: providerName, providerEventId: evt.providerEventId },
|
|
8314
|
+
"mailery: webhook dedupe insert failed"
|
|
8315
|
+
);
|
|
7598
8316
|
}
|
|
7599
8317
|
}
|
|
7600
8318
|
if (events.length > 0) {
|
|
@@ -7603,15 +8321,94 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7603
8321
|
} catch {
|
|
7604
8322
|
}
|
|
7605
8323
|
}
|
|
7606
|
-
});
|
|
8324
|
+
}));
|
|
7607
8325
|
return router;
|
|
7608
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
|
+
}
|
|
7609
8369
|
function sendUnsubError(res, msg) {
|
|
7610
8370
|
return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
|
|
7611
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
|
+
}
|
|
7612
8396
|
function escapeHtml(s) {
|
|
7613
8397
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7614
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
|
+
}
|
|
7615
8412
|
function lowercaseHeaders(h) {
|
|
7616
8413
|
const out = {};
|
|
7617
8414
|
for (const k of Object.keys(h)) {
|
|
@@ -7780,14 +8577,16 @@ var DEDUPE_POLICIES = [
|
|
|
7780
8577
|
];
|
|
7781
8578
|
|
|
7782
8579
|
// src/server/index.ts
|
|
7783
|
-
var VERSION = "0.
|
|
8580
|
+
var VERSION = "0.15.0" ;
|
|
7784
8581
|
|
|
7785
8582
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
8583
|
+
exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;
|
|
7786
8584
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|
|
7787
8585
|
exports.Mailer = Mailer;
|
|
7788
8586
|
exports.NullProvider = NullProvider;
|
|
7789
8587
|
exports.PREDICATE_KINDS = PREDICATE_KINDS;
|
|
7790
8588
|
exports.SEGMENT_FILTER_KINDS = SEGMENT_FILTER_KINDS;
|
|
8589
|
+
exports.TRACKING_SIG_LENGTH = TRACKING_SIG_LENGTH;
|
|
7791
8590
|
exports.VERSION = VERSION;
|
|
7792
8591
|
exports.applyTracking = applyTracking;
|
|
7793
8592
|
exports.applyWebhookEvent = applyWebhookEvent;
|
|
@@ -7802,6 +8601,7 @@ exports.defaultSegmentFilter = defaultSegmentFilter;
|
|
|
7802
8601
|
exports.defineVars = defineVars;
|
|
7803
8602
|
exports.derivePlaintext = derivePlaintext;
|
|
7804
8603
|
exports.dispatchSend = dispatchSend;
|
|
8604
|
+
exports.drainPendingUnsubscribes = drainPendingUnsubscribes;
|
|
7805
8605
|
exports.ensureIndexes = ensureIndexes;
|
|
7806
8606
|
exports.getCollections = getCollections;
|
|
7807
8607
|
exports.predicateKind = predicateKind;
|
|
@@ -7809,11 +8609,14 @@ exports.processNewlyFiredEventTriggers = processNewlyFiredEventTriggers;
|
|
|
7809
8609
|
exports.processOneRunStep = processOneRunStep;
|
|
7810
8610
|
exports.renderTemplate = renderTemplate;
|
|
7811
8611
|
exports.runTick = runTick;
|
|
8612
|
+
exports.sendgridInboundParser = sendgridInboundParser;
|
|
7812
8613
|
exports.sha256Hex = sha256Hex;
|
|
8614
|
+
exports.signTrackingToken = signTrackingToken;
|
|
7813
8615
|
exports.signUnsubscribeToken = signUnsubscribeToken;
|
|
7814
8616
|
exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
|
|
7815
8617
|
exports.validateSenderDomain = validateSenderDomain;
|
|
7816
8618
|
exports.varsJsonSchema = varsJsonSchema;
|
|
8619
|
+
exports.verifyTrackingToken = verifyTrackingToken;
|
|
7817
8620
|
exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
|
|
7818
8621
|
//# sourceMappingURL=index.cjs.map
|
|
7819
8622
|
//# sourceMappingURL=index.cjs.map
|