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.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ObjectId } from 'mongodb';
|
|
3
|
-
import crypto2 from 'crypto';
|
|
3
|
+
import crypto2, { randomBytes, createHash, timingSafeEqual } from 'crypto';
|
|
4
4
|
import sgMail from '@sendgrid/mail';
|
|
5
5
|
import IORedis from 'ioredis';
|
|
6
6
|
import Handlebars from 'handlebars';
|
|
@@ -9,22 +9,22 @@ import mjml2html from 'mjml';
|
|
|
9
9
|
import dns from 'dns/promises';
|
|
10
10
|
import net from 'net';
|
|
11
11
|
import psl from 'psl';
|
|
12
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
12
15
|
import express, { Router } from 'express';
|
|
13
16
|
import multer from 'multer';
|
|
14
|
-
import path from 'path';
|
|
15
17
|
import { fileURLToPath } from 'url';
|
|
16
|
-
import fs from 'fs';
|
|
17
18
|
|
|
18
19
|
var __defProp = Object.defineProperty;
|
|
19
20
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
21
|
+
var __esm = (fn, res, err) => function __init() {
|
|
22
|
+
if (err) throw err[0];
|
|
23
|
+
try {
|
|
24
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw err = [e], e;
|
|
27
|
+
}
|
|
28
28
|
};
|
|
29
29
|
var __export = (target, all) => {
|
|
30
30
|
for (var name in all)
|
|
@@ -240,6 +240,31 @@ var init_mongo = __esm({
|
|
|
240
240
|
}
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
// src/server/providers/webhook-tolerance.ts
|
|
244
|
+
function resolveWebhookToleranceSeconds(option) {
|
|
245
|
+
if (option === false || option === 0) return 0;
|
|
246
|
+
if (option === void 0 || option === null) return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
247
|
+
if (typeof option !== "number" || !Number.isFinite(option) || option < 0) {
|
|
248
|
+
return DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
249
|
+
}
|
|
250
|
+
return option;
|
|
251
|
+
}
|
|
252
|
+
function isWebhookTimestampFresh(rawTimestamp, toleranceSeconds, now = /* @__PURE__ */ new Date()) {
|
|
253
|
+
if (toleranceSeconds === 0) return true;
|
|
254
|
+
if (!rawTimestamp) return false;
|
|
255
|
+
if (!/^\d{1,15}$/.test(rawTimestamp)) return false;
|
|
256
|
+
const timestampMs = Number(rawTimestamp) * 1e3;
|
|
257
|
+
if (!Number.isFinite(timestampMs)) return false;
|
|
258
|
+
const deltaMs = Math.abs(now.getTime() - timestampMs);
|
|
259
|
+
return deltaMs <= toleranceSeconds * 1e3;
|
|
260
|
+
}
|
|
261
|
+
var DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
|
|
262
|
+
var init_webhook_tolerance = __esm({
|
|
263
|
+
"src/server/providers/webhook-tolerance.ts"() {
|
|
264
|
+
DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
243
268
|
// src/server/providers/sendgrid.ts
|
|
244
269
|
var sendgrid_exports = {};
|
|
245
270
|
__export(sendgrid_exports, {
|
|
@@ -286,6 +311,7 @@ function mapEventType(sgEvent) {
|
|
|
286
311
|
var SG_SIG_HEADER, SG_TS_HEADER, SendGridProvider;
|
|
287
312
|
var init_sendgrid = __esm({
|
|
288
313
|
"src/server/providers/sendgrid.ts"() {
|
|
314
|
+
init_webhook_tolerance();
|
|
289
315
|
SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
|
|
290
316
|
SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
|
|
291
317
|
SendGridProvider = class {
|
|
@@ -293,10 +319,13 @@ var init_sendgrid = __esm({
|
|
|
293
319
|
this.opts = opts;
|
|
294
320
|
sgMail.setApiKey(opts.apiKey);
|
|
295
321
|
this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
|
|
322
|
+
this.webhookToleranceSeconds = resolveWebhookToleranceSeconds(opts.webhookToleranceSeconds);
|
|
296
323
|
}
|
|
297
324
|
opts;
|
|
298
325
|
name = "sendgrid";
|
|
299
326
|
sendRatePerSecond;
|
|
327
|
+
/** Resolved replay window in seconds; `0` means the check is disabled. */
|
|
328
|
+
webhookToleranceSeconds;
|
|
300
329
|
async send(args) {
|
|
301
330
|
const msg = {
|
|
302
331
|
to: args.to,
|
|
@@ -336,10 +365,11 @@ var init_sendgrid = __esm({
|
|
|
336
365
|
try {
|
|
337
366
|
const verifier = crypto2.createVerify("sha256");
|
|
338
367
|
verifier.update(payload);
|
|
339
|
-
|
|
368
|
+
if (!verifier.verify(this.opts.webhookVerificationKey, sig, "base64")) return false;
|
|
340
369
|
} catch {
|
|
341
370
|
return false;
|
|
342
371
|
}
|
|
372
|
+
return isWebhookTimestampFresh(ts, this.webhookToleranceSeconds);
|
|
343
373
|
}
|
|
344
374
|
parseWebhookEvents(payload) {
|
|
345
375
|
if (!Array.isArray(payload)) return [];
|
|
@@ -469,6 +499,10 @@ var flowStepSchema = z.lazy(
|
|
|
469
499
|
}),
|
|
470
500
|
z.object({
|
|
471
501
|
type: z.literal("webhook"),
|
|
502
|
+
// `url` is validated for shape only and is deliberately never templated —
|
|
503
|
+
// see the comment on handleWebhookStep in src/server/runner/step.ts, and
|
|
504
|
+
// INVARIANT 16. Rendering contact/event data into this field would turn an
|
|
505
|
+
// admin-authored flow into a server-side request forgery primitive.
|
|
472
506
|
url: z.string().url(),
|
|
473
507
|
method: z.enum(["POST", "PUT"]).optional(),
|
|
474
508
|
payload: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -562,6 +596,7 @@ var DEFAULTS = {
|
|
|
562
596
|
requireDoubleOptIn: false,
|
|
563
597
|
unsubscribeTokenLifetimeDays: 90,
|
|
564
598
|
transactionalRespectUnsubscribe: false,
|
|
599
|
+
unsubscribeWriteTimeoutMs: 5e3,
|
|
565
600
|
doiTemplateSlug: "doi-confirmation",
|
|
566
601
|
doiTokenLifetimeDays: 7,
|
|
567
602
|
broadcastConfirmationThreshold: 1e3,
|
|
@@ -578,7 +613,9 @@ var DEFAULTS = {
|
|
|
578
613
|
trackOpens: true,
|
|
579
614
|
trackClicks: true,
|
|
580
615
|
storeTrackingIp: false,
|
|
581
|
-
storeRenderedBody: false
|
|
616
|
+
storeRenderedBody: false,
|
|
617
|
+
requireSignedTrackingUrls: false,
|
|
618
|
+
trackingUrlLifetimeDays: 0
|
|
582
619
|
};
|
|
583
620
|
var CIRCUIT_BREAKER_DEFAULTS = {
|
|
584
621
|
hardBounceRatePctTrip: 2,
|
|
@@ -595,6 +632,7 @@ function resolveConfig(c) {
|
|
|
595
632
|
requireDoubleOptIn: c.requireDoubleOptIn ?? DEFAULTS.requireDoubleOptIn,
|
|
596
633
|
unsubscribeTokenLifetimeDays: c.unsubscribeTokenLifetimeDays ?? DEFAULTS.unsubscribeTokenLifetimeDays,
|
|
597
634
|
transactionalRespectUnsubscribe: c.transactionalRespectUnsubscribe ?? DEFAULTS.transactionalRespectUnsubscribe,
|
|
635
|
+
unsubscribeWriteTimeoutMs: c.unsubscribeWriteTimeoutMs ?? DEFAULTS.unsubscribeWriteTimeoutMs,
|
|
598
636
|
doiTemplateSlug: c.doiTemplateSlug ?? DEFAULTS.doiTemplateSlug,
|
|
599
637
|
doiTokenLifetimeDays: c.doiTokenLifetimeDays ?? DEFAULTS.doiTokenLifetimeDays,
|
|
600
638
|
broadcastConfirmationThreshold: c.broadcastConfirmationThreshold ?? DEFAULTS.broadcastConfirmationThreshold,
|
|
@@ -612,6 +650,8 @@ function resolveConfig(c) {
|
|
|
612
650
|
trackClicks: c.trackClicks ?? DEFAULTS.trackClicks,
|
|
613
651
|
storeTrackingIp: c.storeTrackingIp ?? DEFAULTS.storeTrackingIp,
|
|
614
652
|
storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
|
|
653
|
+
requireSignedTrackingUrls: c.requireSignedTrackingUrls ?? DEFAULTS.requireSignedTrackingUrls,
|
|
654
|
+
trackingUrlLifetimeDays: c.trackingUrlLifetimeDays ?? DEFAULTS.trackingUrlLifetimeDays,
|
|
615
655
|
circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
|
|
616
656
|
};
|
|
617
657
|
}
|
|
@@ -814,6 +854,22 @@ function isoDay(d) {
|
|
|
814
854
|
function randomId() {
|
|
815
855
|
return crypto2.randomBytes(8).toString("hex");
|
|
816
856
|
}
|
|
857
|
+
|
|
858
|
+
// src/server/provider-lookup.ts
|
|
859
|
+
var PROVIDER_METHODS = ["send", "verifyWebhook", "parseWebhookEvents"];
|
|
860
|
+
function isMailProvider(candidate) {
|
|
861
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
862
|
+
const p = candidate;
|
|
863
|
+
return PROVIDER_METHODS.every((m) => typeof p[m] === "function");
|
|
864
|
+
}
|
|
865
|
+
function resolveProvider(providers, name) {
|
|
866
|
+
if (typeof name !== "string" || !Object.hasOwn(providers, name)) return null;
|
|
867
|
+
const candidate = providers[name];
|
|
868
|
+
return isMailProvider(candidate) ? candidate : null;
|
|
869
|
+
}
|
|
870
|
+
function registeredProviderNames(providers) {
|
|
871
|
+
return Object.keys(providers).filter((name) => resolveProvider(providers, name) !== null).sort();
|
|
872
|
+
}
|
|
817
873
|
function signUnsubscribeToken(payload, secret) {
|
|
818
874
|
const body = JSON.stringify({
|
|
819
875
|
e: payload.email.toLowerCase(),
|
|
@@ -852,6 +908,28 @@ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date())
|
|
|
852
908
|
expiresAt: new Date(body.x)
|
|
853
909
|
};
|
|
854
910
|
}
|
|
911
|
+
var TRACKING_SCOPES = ["open", "click"];
|
|
912
|
+
var TRACKING_SIG_LENGTH = 12;
|
|
913
|
+
function trackingMessage(scope, params) {
|
|
914
|
+
const tail = params.linkId === void 0 ? "" : `:${params.linkId}`;
|
|
915
|
+
return `mailery.track.v1:${scope}:${params.sendId}${tail}`;
|
|
916
|
+
}
|
|
917
|
+
function signTrackingToken(scope, params, secret) {
|
|
918
|
+
if (!TRACKING_SCOPES.includes(scope)) {
|
|
919
|
+
throw new Error(`signTrackingToken: unknown scope ${String(scope)}`);
|
|
920
|
+
}
|
|
921
|
+
const mac = crypto2.createHmac("sha256", secret).update(trackingMessage(scope, params)).digest();
|
|
922
|
+
return b64url(mac).slice(0, TRACKING_SIG_LENGTH);
|
|
923
|
+
}
|
|
924
|
+
function verifyTrackingToken(token, scope, params, secret) {
|
|
925
|
+
if (typeof token !== "string") return false;
|
|
926
|
+
if (!TRACKING_SCOPES.includes(scope)) return false;
|
|
927
|
+
if (token.length !== TRACKING_SIG_LENGTH) return false;
|
|
928
|
+
const expected = Buffer.from(signTrackingToken(scope, params, secret), "utf8");
|
|
929
|
+
const actual = Buffer.from(token, "utf8");
|
|
930
|
+
if (expected.length !== actual.length) return false;
|
|
931
|
+
return crypto2.timingSafeEqual(expected, actual);
|
|
932
|
+
}
|
|
855
933
|
function b64url(buf) {
|
|
856
934
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
857
935
|
}
|
|
@@ -895,6 +973,39 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
895
973
|
if (body.x < now.getTime()) return null;
|
|
896
974
|
return { externalId: body.i, expiresAt: new Date(body.x) };
|
|
897
975
|
}
|
|
976
|
+
|
|
977
|
+
// src/server/unsubscribe.ts
|
|
978
|
+
async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
|
|
979
|
+
const normalized = input.email;
|
|
980
|
+
await collections.suppressions.updateOne(
|
|
981
|
+
{ email: normalized, scope: input.scope },
|
|
982
|
+
{
|
|
983
|
+
$setOnInsert: {
|
|
984
|
+
email: normalized,
|
|
985
|
+
emailHash: sha256Hex(normalized),
|
|
986
|
+
scope: input.scope,
|
|
987
|
+
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
988
|
+
reason: "unsubscribed",
|
|
989
|
+
source: input.source,
|
|
990
|
+
notes: input.notes ?? null,
|
|
991
|
+
addedAt: now,
|
|
992
|
+
expiresAt: null
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
{ upsert: true }
|
|
996
|
+
);
|
|
997
|
+
await collections.subscriptions.updateOne(
|
|
998
|
+
{ emailAtSubscribe: normalized },
|
|
999
|
+
{
|
|
1000
|
+
$set: {
|
|
1001
|
+
status: "unsubscribed",
|
|
1002
|
+
unsubscribedAt: now,
|
|
1003
|
+
unsubscribeReason: input.reason,
|
|
1004
|
+
updatedAt: now
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
898
1009
|
var QUEUE_NAMES = {
|
|
899
1010
|
tick: "mailer-tick",
|
|
900
1011
|
advance: "mailer-advance",
|
|
@@ -1727,7 +1838,7 @@ async function suppressOnce(ctx, email, reason, scope) {
|
|
|
1727
1838
|
}
|
|
1728
1839
|
|
|
1729
1840
|
// src/server/runner/predicate.ts
|
|
1730
|
-
var
|
|
1841
|
+
var DEFAULT_BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
|
|
1731
1842
|
async function evaluatePredicate(predicate, ctx) {
|
|
1732
1843
|
const p = predicate;
|
|
1733
1844
|
if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
|
|
@@ -1795,17 +1906,35 @@ async function openOrClickCount(ctx, kind, opts, excludeBots) {
|
|
|
1795
1906
|
return await ctx.collections.sends.countDocuments(filter);
|
|
1796
1907
|
}
|
|
1797
1908
|
const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
|
|
1909
|
+
const botRe = ctx.botFilter?.userAgentPattern ?? DEFAULT_BOT_UA_RE;
|
|
1910
|
+
const minOpenDelayMs = ctx.botFilter?.minOpenDelayMs ?? 0;
|
|
1798
1911
|
let n = 0;
|
|
1799
1912
|
for (const s of docs) {
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
}
|
|
1804
|
-
const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
|
|
1805
|
-
if (hasHumanClick) n++;
|
|
1913
|
+
const doc = s;
|
|
1914
|
+
const human = kind === "opened" ? hasHumanOpen(doc, botRe, minOpenDelayMs) : (doc.clickedLinks ?? []).some((c) => !isBotUserAgent(c.userAgent, botRe));
|
|
1915
|
+
if (human) n++;
|
|
1806
1916
|
}
|
|
1807
1917
|
return n;
|
|
1808
1918
|
}
|
|
1919
|
+
function isBotUserAgent(ua, botRe) {
|
|
1920
|
+
if (typeof ua !== "string" || ua.trim() === "") return false;
|
|
1921
|
+
return botRe.test(ua);
|
|
1922
|
+
}
|
|
1923
|
+
function hasHumanOpen(send, botRe, minOpenDelayMs) {
|
|
1924
|
+
const opens = send.opens && send.opens.length > 0 ? send.opens : send.openedAt ? [{ openedAt: send.openedAt, userAgent: null }] : [];
|
|
1925
|
+
return opens.some((o) => {
|
|
1926
|
+
if (isBotUserAgent(o.userAgent, botRe)) return false;
|
|
1927
|
+
if (isPrefetchOpen(o.openedAt, send.queuedAt, minOpenDelayMs)) return false;
|
|
1928
|
+
return true;
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
function isPrefetchOpen(openedAt, queuedAt, minOpenDelayMs) {
|
|
1932
|
+
if (!minOpenDelayMs || minOpenDelayMs <= 0) return false;
|
|
1933
|
+
if (!(openedAt instanceof Date) || !(queuedAt instanceof Date)) return false;
|
|
1934
|
+
const delta = openedAt.getTime() - queuedAt.getTime();
|
|
1935
|
+
if (!Number.isFinite(delta) || delta < 0) return false;
|
|
1936
|
+
return delta < minOpenDelayMs;
|
|
1937
|
+
}
|
|
1809
1938
|
function effectiveLowerBound(ctx, opts) {
|
|
1810
1939
|
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1811
1940
|
if (opts.sinceFlowStart) return ctx.run.enteredAt;
|
|
@@ -1933,6 +2062,7 @@ function derivePlaintext(html) {
|
|
|
1933
2062
|
]
|
|
1934
2063
|
}).trim();
|
|
1935
2064
|
}
|
|
2065
|
+
var PLAIN_TEXT = { noEscape: true };
|
|
1936
2066
|
async function renderTemplate(template, ctx, opts = {}) {
|
|
1937
2067
|
const hb = makeHandlebars(opts.helpers);
|
|
1938
2068
|
const subject = hb.compile(template.subject)(ctx);
|
|
@@ -1944,7 +2074,7 @@ async function renderTemplate(template, ctx, opts = {}) {
|
|
|
1944
2074
|
const compiled = await compileTemplate(template.body.mjml);
|
|
1945
2075
|
html = hb.compile(compiled.html)(ctx);
|
|
1946
2076
|
}
|
|
1947
|
-
const plainText = template.body.plainText ? hb.compile(template.body.plainText)(ctx) : derivePlaintext(html);
|
|
2077
|
+
const plainText = template.body.plainText ? hb.compile(template.body.plainText, PLAIN_TEXT)(ctx) : derivePlaintext(html);
|
|
1948
2078
|
return {
|
|
1949
2079
|
subject,
|
|
1950
2080
|
preheader,
|
|
@@ -1970,12 +2100,14 @@ function applyTracking(html, opts) {
|
|
|
1970
2100
|
seen.set(url, linkId);
|
|
1971
2101
|
links.push({ linkId, url });
|
|
1972
2102
|
}
|
|
1973
|
-
const
|
|
2103
|
+
const sig = opts.signingSecret ? `/${signTrackingToken("click", { sendId: opts.sendId, linkId }, opts.signingSecret)}` : "";
|
|
2104
|
+
const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}${sig}`;
|
|
1974
2105
|
return `<a${pre}href="${newHref}"${post}>`;
|
|
1975
2106
|
});
|
|
1976
2107
|
}
|
|
1977
2108
|
if (opts.trackOpens) {
|
|
1978
|
-
const
|
|
2109
|
+
const sig = opts.signingSecret ? `.${signTrackingToken("open", { sendId: opts.sendId }, opts.signingSecret)}` : "";
|
|
2110
|
+
const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}${sig}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
|
|
1979
2111
|
if (/<\/body>/i.test(out)) {
|
|
1980
2112
|
out = out.replace(/<\/body>/i, `${pixel}</body>`);
|
|
1981
2113
|
} else {
|
|
@@ -1986,14 +2118,22 @@ function applyTracking(html, opts) {
|
|
|
1986
2118
|
}
|
|
1987
2119
|
function shouldSkipClickRewrite(url, preserve, fullTag) {
|
|
1988
2120
|
if (!url) return true;
|
|
1989
|
-
if (url.startsWith("mailto:")) return true;
|
|
1990
|
-
if (url.startsWith("tel:")) return true;
|
|
1991
2121
|
if (url.startsWith("#")) return true;
|
|
1992
2122
|
if (url.startsWith("{{") || url.startsWith("{")) return true;
|
|
1993
2123
|
if (preserve.has(url.trim())) return true;
|
|
1994
2124
|
if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
|
|
2125
|
+
if (!isHttpUrl(url)) return true;
|
|
1995
2126
|
return false;
|
|
1996
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
|
+
}
|
|
1997
2137
|
var NAMED_ENTITIES = {
|
|
1998
2138
|
amp: "&",
|
|
1999
2139
|
lt: "<",
|
|
@@ -2214,7 +2354,8 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2214
2354
|
publicUrl: ctx.config.publicUrl,
|
|
2215
2355
|
trackOpens: template.trackOpens ?? ctx.config.trackOpens,
|
|
2216
2356
|
trackClicks: template.trackClicks ?? ctx.config.trackClicks,
|
|
2217
|
-
preserveUrls: [renderCtx.unsubscribeUrl]
|
|
2357
|
+
preserveUrls: [renderCtx.unsubscribeUrl],
|
|
2358
|
+
signingSecret: ctx.config.unsubscribeSecret
|
|
2218
2359
|
});
|
|
2219
2360
|
await ctx.collections.sends.updateOne(
|
|
2220
2361
|
{ _id: send._id },
|
|
@@ -2232,9 +2373,14 @@ async function dispatchSend(sendId, ctx) {
|
|
|
2232
2373
|
}
|
|
2233
2374
|
}
|
|
2234
2375
|
);
|
|
2235
|
-
const provider = ctx.providers
|
|
2376
|
+
const provider = resolveProvider(ctx.providers, send.provider);
|
|
2236
2377
|
if (!provider) {
|
|
2237
|
-
|
|
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
|
+
);
|
|
2238
2384
|
return;
|
|
2239
2385
|
}
|
|
2240
2386
|
const headers = send.kind === "marketing" ? {
|
|
@@ -2433,7 +2579,7 @@ async function handleWait(run, step, ctx) {
|
|
|
2433
2579
|
}
|
|
2434
2580
|
async function handleCondition(run, step, contact, ctx) {
|
|
2435
2581
|
if (!contact) return;
|
|
2436
|
-
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 });
|
|
2437
2583
|
if (result) {
|
|
2438
2584
|
await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
|
|
2439
2585
|
return;
|
|
@@ -2446,7 +2592,7 @@ async function handleCondition(run, step, contact, ctx) {
|
|
|
2446
2592
|
}
|
|
2447
2593
|
async function handleBranch(run, step, contact, ctx) {
|
|
2448
2594
|
if (!contact) return;
|
|
2449
|
-
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 });
|
|
2450
2596
|
const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
|
|
2451
2597
|
const updated = await ctx.collections.flowRuns.findOneAndUpdate(
|
|
2452
2598
|
{ _id: run._id, currentStepIndex: run.currentStepIndex },
|
|
@@ -3590,8 +3736,6 @@ function nthSundayOfMonth(year, month0, n) {
|
|
|
3590
3736
|
const firstSunday = 1 + (7 - dayOfWeek) % 7;
|
|
3591
3737
|
return firstSunday + (n - 1) * 7;
|
|
3592
3738
|
}
|
|
3593
|
-
|
|
3594
|
-
// src/server/runner/dmarc.ts
|
|
3595
3739
|
var MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;
|
|
3596
3740
|
function assertSafeZipEntryName(rawName) {
|
|
3597
3741
|
if (typeof rawName !== "string" || rawName.length === 0) {
|
|
@@ -3665,7 +3809,6 @@ async function extractDmarcXmls(buffer, filename) {
|
|
|
3665
3809
|
return [buffer.toString("utf8")];
|
|
3666
3810
|
}
|
|
3667
3811
|
function parseDmarcReport(xml) {
|
|
3668
|
-
const { XMLParser } = __require("fast-xml-parser");
|
|
3669
3812
|
const parser = new XMLParser({
|
|
3670
3813
|
ignoreAttributes: false,
|
|
3671
3814
|
parseAttributeValue: false,
|
|
@@ -3756,7 +3899,7 @@ function toArray(v) {
|
|
|
3756
3899
|
if (v === void 0 || v === null) return [];
|
|
3757
3900
|
return Array.isArray(v) ? v : [v];
|
|
3758
3901
|
}
|
|
3759
|
-
async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
3902
|
+
async function ingestDmarcAttachment(ctx, buffer, filename, opts = {}) {
|
|
3760
3903
|
const xmls = await extractDmarcXmls(buffer, filename);
|
|
3761
3904
|
if (xmls.length === 0) throw new Error("attachment contained no XML payload");
|
|
3762
3905
|
const results = [];
|
|
@@ -3764,6 +3907,9 @@ async function ingestDmarcAttachment(ctx, buffer, filename) {
|
|
|
3764
3907
|
for (const xml of xmls) {
|
|
3765
3908
|
try {
|
|
3766
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
|
+
}
|
|
3767
3913
|
results.push(await ingestParsedDmarcReport(ctx, parsed));
|
|
3768
3914
|
} catch (err) {
|
|
3769
3915
|
if (!firstError) firstError = err;
|
|
@@ -3874,6 +4020,260 @@ function suggestPolicyProgression(input) {
|
|
|
3874
4020
|
}
|
|
3875
4021
|
return null;
|
|
3876
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
|
+
}
|
|
3877
4277
|
|
|
3878
4278
|
// src/server/runner/tick.ts
|
|
3879
4279
|
var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
@@ -3915,6 +4315,9 @@ async function runTick(ctx) {
|
|
|
3915
4315
|
await drainOutbox(ctx).catch((err) => {
|
|
3916
4316
|
console.error("mailery: outbox drain failed", err);
|
|
3917
4317
|
});
|
|
4318
|
+
await drainPendingUnsubscribes(ctx).catch((err) => {
|
|
4319
|
+
console.error("mailery: pending-unsubscribe drain failed", err);
|
|
4320
|
+
});
|
|
3918
4321
|
await processScheduledBroadcasts2(ctx).catch((err) => {
|
|
3919
4322
|
console.error("mailery: broadcast dispatch failed", err);
|
|
3920
4323
|
});
|
|
@@ -4047,6 +4450,10 @@ var Mailer = class _Mailer {
|
|
|
4047
4450
|
* MAILER_FROM_NAME / MAILER_FROM_EMAIL
|
|
4048
4451
|
* MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
|
|
4049
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.
|
|
4050
4457
|
* MAILER_HOST_USERS_COLLECTION (default 'users')
|
|
4051
4458
|
* MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
|
|
4052
4459
|
* MAILER_HOST_USERS_ID_FIELD (default '_id')
|
|
@@ -4081,6 +4488,10 @@ var Mailer = class _Mailer {
|
|
|
4081
4488
|
providers.sendgrid = new SendGridProvider2({
|
|
4082
4489
|
apiKey: env.MAILER_SENDGRID_API_KEY,
|
|
4083
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,
|
|
4084
4495
|
sandbox: env.NODE_ENV !== "production"
|
|
4085
4496
|
});
|
|
4086
4497
|
}
|
|
@@ -4108,8 +4519,15 @@ var Mailer = class _Mailer {
|
|
|
4108
4519
|
}
|
|
4109
4520
|
static async init(input) {
|
|
4110
4521
|
const config = resolveConfig(input);
|
|
4111
|
-
if (!config.providers
|
|
4112
|
-
throw new Error(
|
|
4522
|
+
if (!resolveProvider(config.providers, config.defaultProvider)) {
|
|
4523
|
+
throw new Error(
|
|
4524
|
+
`defaultProvider "${config.defaultProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4525
|
+
);
|
|
4526
|
+
}
|
|
4527
|
+
if (config.defaultTransactionalProvider != null && !resolveProvider(config.providers, config.defaultTransactionalProvider)) {
|
|
4528
|
+
throw new Error(
|
|
4529
|
+
`defaultTransactionalProvider "${config.defaultTransactionalProvider}" is not a registered provider. Registered: ${registeredProviderNames(config.providers).join(", ") || "(none)"}`
|
|
4530
|
+
);
|
|
4113
4531
|
}
|
|
4114
4532
|
if (config.varsAdapter) {
|
|
4115
4533
|
const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
|
|
@@ -4251,38 +4669,14 @@ var Mailer = class _Mailer {
|
|
|
4251
4669
|
}
|
|
4252
4670
|
}
|
|
4253
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
|
+
*/
|
|
4254
4677
|
async unsubscribe(email, opts) {
|
|
4255
4678
|
const parsed = unsubscribeInputSchema.parse({ email, ...opts });
|
|
4256
|
-
|
|
4257
|
-
const now = /* @__PURE__ */ new Date();
|
|
4258
|
-
await this.collections.suppressions.updateOne(
|
|
4259
|
-
{ email: normalized, scope: parsed.scope },
|
|
4260
|
-
{
|
|
4261
|
-
$setOnInsert: {
|
|
4262
|
-
email: normalized,
|
|
4263
|
-
emailHash: sha256Hex(normalized),
|
|
4264
|
-
scope: parsed.scope,
|
|
4265
|
-
// mailer_suppressions canonical reason — see plans/02-data-model.md.
|
|
4266
|
-
reason: "unsubscribed",
|
|
4267
|
-
source: parsed.source,
|
|
4268
|
-
notes: parsed.notes ?? null,
|
|
4269
|
-
addedAt: now,
|
|
4270
|
-
expiresAt: null
|
|
4271
|
-
}
|
|
4272
|
-
},
|
|
4273
|
-
{ upsert: true }
|
|
4274
|
-
);
|
|
4275
|
-
await this.collections.subscriptions.updateOne(
|
|
4276
|
-
{ emailAtSubscribe: normalized },
|
|
4277
|
-
{
|
|
4278
|
-
$set: {
|
|
4279
|
-
status: "unsubscribed",
|
|
4280
|
-
unsubscribedAt: now,
|
|
4281
|
-
unsubscribeReason: parsed.reason,
|
|
4282
|
-
updatedAt: now
|
|
4283
|
-
}
|
|
4284
|
-
}
|
|
4285
|
-
);
|
|
4679
|
+
await applyUnsubscribe(this.collections, parsed);
|
|
4286
4680
|
}
|
|
4287
4681
|
async suppress(email, opts) {
|
|
4288
4682
|
const parsed = suppressInputSchema.parse({ email, ...opts });
|
|
@@ -4618,8 +5012,20 @@ var NullProvider = class {
|
|
|
4618
5012
|
status: "accepted"
|
|
4619
5013
|
};
|
|
4620
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
|
+
*/
|
|
4621
5027
|
async verifyWebhook() {
|
|
4622
|
-
return
|
|
5028
|
+
return false;
|
|
4623
5029
|
}
|
|
4624
5030
|
parseWebhookEvents() {
|
|
4625
5031
|
return [];
|
|
@@ -6761,11 +7167,11 @@ function apiRouter(mailer, opts = {}) {
|
|
|
6761
7167
|
if (cached) {
|
|
6762
7168
|
return res.json({ cached: true, status: "ready", score: cached });
|
|
6763
7169
|
}
|
|
6764
|
-
const
|
|
6765
|
-
const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
|
|
7170
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
6766
7171
|
if (!provider) {
|
|
6767
|
-
return res.status(
|
|
7172
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
6768
7173
|
}
|
|
7174
|
+
const { checkId, emailAddress } = await client.provisionCheck();
|
|
6769
7175
|
try {
|
|
6770
7176
|
await provider.send({
|
|
6771
7177
|
to: emailAddress,
|
|
@@ -7114,7 +7520,10 @@ function apiRouter(mailer, opts = {}) {
|
|
|
7114
7520
|
trackOpens: false,
|
|
7115
7521
|
trackClicks: false
|
|
7116
7522
|
});
|
|
7117
|
-
const provider = mailer.providers
|
|
7523
|
+
const provider = resolveProvider(mailer.providers, tpl.providerOverride ?? mailer.config.defaultProvider);
|
|
7524
|
+
if (!provider) {
|
|
7525
|
+
return res.status(400).json(unknownProviderError(mailer, tpl));
|
|
7526
|
+
}
|
|
7118
7527
|
const result = await provider.send({
|
|
7119
7528
|
to,
|
|
7120
7529
|
fromName: rendered.fromName,
|
|
@@ -7266,6 +7675,18 @@ function asyncHandler(fn) {
|
|
|
7266
7675
|
fn(req, res, next).catch(next);
|
|
7267
7676
|
};
|
|
7268
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
|
+
}
|
|
7269
7690
|
async function collectQueueCounts(mailer) {
|
|
7270
7691
|
const qs = Object.values(mailer.queues);
|
|
7271
7692
|
const sum = async (key) => {
|
|
@@ -7401,13 +7822,227 @@ async function computeBroadcastStats(mailer, idFilter) {
|
|
|
7401
7822
|
}
|
|
7402
7823
|
return out;
|
|
7403
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
|
|
7404
8019
|
var PIXEL = Buffer.from(
|
|
7405
8020
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
7406
8021
|
"base64"
|
|
7407
8022
|
);
|
|
7408
8023
|
function createPublicRouter(mailer, opts = {}) {
|
|
7409
8024
|
const router = Router();
|
|
7410
|
-
const
|
|
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
|
+
}
|
|
7411
8046
|
router.use(
|
|
7412
8047
|
"/webhooks",
|
|
7413
8048
|
express.json({
|
|
@@ -7418,42 +8053,96 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7418
8053
|
})
|
|
7419
8054
|
);
|
|
7420
8055
|
router.use("/unsub", express.urlencoded({ extended: false }));
|
|
7421
|
-
router.get("/open/:sendId.png", async (req, res) => {
|
|
8056
|
+
router.get("/open/:sendId.png", wrap(logger, async (req, res) => {
|
|
7422
8057
|
res.setHeader("Content-Type", "image/png");
|
|
7423
8058
|
res.setHeader("Content-Length", String(PIXEL.length));
|
|
7424
8059
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
7425
8060
|
res.status(200).end(PIXEL);
|
|
7426
|
-
const
|
|
8061
|
+
const parsed = splitOpenParam(req.params.sendId);
|
|
8062
|
+
if (!parsed) return;
|
|
8063
|
+
const { id, sig } = parsed;
|
|
7427
8064
|
if (!ObjectId.isValid(id)) return;
|
|
7428
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;
|
|
7429
8076
|
try {
|
|
7430
|
-
const send = await mailer.collections.sends.findOne(
|
|
8077
|
+
const send = await mailer.collections.sends.findOne(
|
|
8078
|
+
{ _id: sendId },
|
|
8079
|
+
{ projection: { openedAt: 1, queuedAt: 1 } }
|
|
8080
|
+
);
|
|
7431
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();
|
|
7432
8087
|
await mailer.collections.sends.updateOne(
|
|
7433
8088
|
{ _id: sendId },
|
|
7434
8089
|
{
|
|
7435
8090
|
$set: {
|
|
7436
|
-
openedAt: send.openedAt ??
|
|
8091
|
+
openedAt: send.openedAt ?? now,
|
|
7437
8092
|
status: "delivered"
|
|
7438
8093
|
},
|
|
7439
|
-
$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
|
+
}
|
|
7440
8105
|
}
|
|
7441
8106
|
);
|
|
7442
8107
|
} catch (err) {
|
|
7443
|
-
|
|
8108
|
+
logger.error?.({ err, sendId: id }, "mailery: open pixel update failed");
|
|
7444
8109
|
}
|
|
7445
|
-
});
|
|
7446
|
-
router.get("/click/:sendId/:linkId", async (req, res) => {
|
|
7447
|
-
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;
|
|
7448
8113
|
if (!ObjectId.isValid(sendIdStr)) return res.status(400).end();
|
|
7449
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();
|
|
7450
8125
|
const send = await mailer.collections.sends.findOne(
|
|
7451
8126
|
{ _id: sendId },
|
|
7452
|
-
{ projection: { links: 1, firstClickAt: 1 } }
|
|
8127
|
+
{ projection: { links: 1, firstClickAt: 1, queuedAt: 1 } }
|
|
7453
8128
|
);
|
|
7454
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
|
+
}
|
|
7455
8137
|
const link = (send.links ?? []).find((l) => l.linkId === linkId);
|
|
7456
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
|
+
}
|
|
7457
8146
|
res.redirect(302, link.url);
|
|
7458
8147
|
try {
|
|
7459
8148
|
await mailer.collections.sends.updateOne(
|
|
@@ -7465,16 +8154,19 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7465
8154
|
clickedLinks: {
|
|
7466
8155
|
url: link.url,
|
|
7467
8156
|
linkId,
|
|
7468
|
-
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)
|
|
7469
8161
|
}
|
|
7470
8162
|
}
|
|
7471
8163
|
}
|
|
7472
8164
|
);
|
|
7473
8165
|
} catch (err) {
|
|
7474
|
-
|
|
8166
|
+
logger.error?.({ err, sendId: sendIdStr, linkId }, "mailery: click recording failed");
|
|
7475
8167
|
}
|
|
7476
|
-
});
|
|
7477
|
-
router.get("/unsub/:token", (req, res) => {
|
|
8168
|
+
}));
|
|
8169
|
+
router.get("/unsub/:token", wrap(logger, (req, res) => {
|
|
7478
8170
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7479
8171
|
if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
|
|
7480
8172
|
res.status(200).type("html").send(`<!doctype html>
|
|
@@ -7489,32 +8181,55 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7489
8181
|
<button type="submit">Unsubscribe</button>
|
|
7490
8182
|
</form>
|
|
7491
8183
|
</body></html>`);
|
|
7492
|
-
});
|
|
7493
|
-
router.post("/unsub/:token", async (req, res) => {
|
|
8184
|
+
}));
|
|
8185
|
+
router.post("/unsub/:token", wrap(logger, async (req, res) => {
|
|
7494
8186
|
const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
|
|
7495
8187
|
if (!decoded) {
|
|
7496
8188
|
res.status(200).end();
|
|
7497
8189
|
return;
|
|
7498
8190
|
}
|
|
7499
|
-
|
|
8191
|
+
const write = mailer.unsubscribe(decoded.email, {
|
|
8192
|
+
scope: decoded.scope,
|
|
8193
|
+
reason: "user_request",
|
|
8194
|
+
source: "one-click"
|
|
8195
|
+
});
|
|
8196
|
+
let dbError = null;
|
|
7500
8197
|
try {
|
|
7501
|
-
await mailer.
|
|
7502
|
-
scope: decoded.scope,
|
|
7503
|
-
reason: "user_request",
|
|
7504
|
-
source: "one-click"
|
|
7505
|
-
});
|
|
8198
|
+
await withTimeout(write, mailer.config.unsubscribeWriteTimeoutMs);
|
|
7506
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
|
+
}
|
|
7507
8212
|
try {
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
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"
|
|
7511
8221
|
);
|
|
7512
8222
|
} catch (diskErr) {
|
|
7513
|
-
|
|
8223
|
+
logger.error?.(
|
|
8224
|
+
{ err: dbError, diskErr, path: pendingUnsubsPath },
|
|
8225
|
+
"mailery: unsub disk fallback failed \u2014 answering 503"
|
|
8226
|
+
);
|
|
8227
|
+
return sendUnsubUnavailable(res);
|
|
7514
8228
|
}
|
|
7515
8229
|
}
|
|
7516
|
-
|
|
7517
|
-
|
|
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) => {
|
|
7518
8233
|
const token = req.params.token;
|
|
7519
8234
|
const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
|
|
7520
8235
|
if (!decoded) {
|
|
@@ -7542,10 +8257,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7542
8257
|
} catch {
|
|
7543
8258
|
}
|
|
7544
8259
|
return res.status(200).type("html").send("<!doctype html><html><body><p>Thanks \u2014 you're subscribed.</p></body></html>");
|
|
7545
|
-
});
|
|
7546
|
-
router.post("/webhooks/:provider", async (req, res) => {
|
|
8260
|
+
}));
|
|
8261
|
+
router.post("/webhooks/:provider", wrap(logger, async (req, res) => {
|
|
7547
8262
|
const providerName = req.params.provider;
|
|
7548
|
-
const provider = mailer.providers
|
|
8263
|
+
const provider = resolveProvider(mailer.providers, providerName);
|
|
7549
8264
|
if (!provider) return res.status(404).end();
|
|
7550
8265
|
const rawBody = req.rawBody;
|
|
7551
8266
|
if (!rawBody) return res.status(400).end();
|
|
@@ -7576,7 +8291,10 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7576
8291
|
{ upsert: true }
|
|
7577
8292
|
);
|
|
7578
8293
|
} catch (err) {
|
|
7579
|
-
|
|
8294
|
+
logger.error?.(
|
|
8295
|
+
{ err, provider: providerName, providerEventId: evt.providerEventId },
|
|
8296
|
+
"mailery: webhook dedupe insert failed"
|
|
8297
|
+
);
|
|
7580
8298
|
}
|
|
7581
8299
|
}
|
|
7582
8300
|
if (events.length > 0) {
|
|
@@ -7585,15 +8303,94 @@ function createPublicRouter(mailer, opts = {}) {
|
|
|
7585
8303
|
} catch {
|
|
7586
8304
|
}
|
|
7587
8305
|
}
|
|
7588
|
-
});
|
|
8306
|
+
}));
|
|
7589
8307
|
return router;
|
|
7590
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
|
+
}
|
|
7591
8351
|
function sendUnsubError(res, msg) {
|
|
7592
8352
|
return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
|
|
7593
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
|
+
}
|
|
7594
8378
|
function escapeHtml(s) {
|
|
7595
8379
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7596
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
|
+
}
|
|
7597
8394
|
function lowercaseHeaders(h) {
|
|
7598
8395
|
const out = {};
|
|
7599
8396
|
for (const k of Object.keys(h)) {
|
|
@@ -7762,8 +8559,8 @@ var DEDUPE_POLICIES = [
|
|
|
7762
8559
|
];
|
|
7763
8560
|
|
|
7764
8561
|
// src/server/index.ts
|
|
7765
|
-
var VERSION = "0.
|
|
8562
|
+
var VERSION = "0.15.0" ;
|
|
7766
8563
|
|
|
7767
|
-
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 };
|
|
7768
8565
|
//# sourceMappingURL=index.js.map
|
|
7769
8566
|
//# sourceMappingURL=index.js.map
|