mailery 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +162 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +162 -8
- package/dist/index.js.map +1 -1
- package/dist/{null-CfzvqNEp.d.cts → null-DDg3ojHe.d.cts} +16 -1
- package/dist/{null-CfzvqNEp.d.ts → null-DDg3ojHe.d.ts} +16 -1
- package/dist/testing.cjs +70 -7
- 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 +70 -7
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1069,6 +1069,9 @@ var QUEUE_NAMES2 = {
|
|
|
1069
1069
|
send: "mailer-send",
|
|
1070
1070
|
webhook: "mailer-webhook"
|
|
1071
1071
|
};
|
|
1072
|
+
var DEFAULT_COLLECTION = "_mailerJobs";
|
|
1073
|
+
var DEFAULT_FAILED_RETENTION_DAYS = 7;
|
|
1074
|
+
var FAILED_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
|
|
1072
1075
|
var AgendaDriver = class _AgendaDriver {
|
|
1073
1076
|
queues;
|
|
1074
1077
|
agenda;
|
|
@@ -1086,7 +1089,7 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1086
1089
|
"mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
|
|
1087
1090
|
);
|
|
1088
1091
|
}
|
|
1089
|
-
const collectionName = opts.collectionName ??
|
|
1092
|
+
const collectionName = opts.collectionName ?? collectionFor(opts.prefix);
|
|
1090
1093
|
const backend = new backendMod.MongoBackend({
|
|
1091
1094
|
mongo: opts.db,
|
|
1092
1095
|
collection: collectionName
|
|
@@ -1096,17 +1099,29 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1096
1099
|
processEvery: `${opts.processEverySeconds ?? 5} seconds`,
|
|
1097
1100
|
defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
|
|
1098
1101
|
maxConcurrency: 50,
|
|
1099
|
-
defaultConcurrency: 5
|
|
1102
|
+
defaultConcurrency: 5,
|
|
1103
|
+
// Drop succeeded one-shot jobs instead of retaining them forever.
|
|
1104
|
+
// Agenda guards this on `!nextRunAt`, so repeating jobs survive.
|
|
1105
|
+
removeOnComplete: true
|
|
1100
1106
|
});
|
|
1101
|
-
|
|
1107
|
+
const retentionDays = opts.failedJobRetentionDays ?? DEFAULT_FAILED_RETENTION_DAYS;
|
|
1108
|
+
try {
|
|
1109
|
+
await opts.db.collection(collectionName).createIndex({ failedAt: 1 }, { sparse: true });
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
console.error("mailery: could not index the Agenda jobs collection on failedAt", err);
|
|
1112
|
+
}
|
|
1113
|
+
return new _AgendaDriver(agenda, agendaMod, opts.db, collectionName, retentionDays);
|
|
1102
1114
|
}
|
|
1103
1115
|
db;
|
|
1104
1116
|
collName;
|
|
1105
|
-
|
|
1117
|
+
failedRetentionDays;
|
|
1118
|
+
sweepTimer = null;
|
|
1119
|
+
constructor(agenda, agendaMod, db, collectionName, failedRetentionDays) {
|
|
1106
1120
|
this.agenda = agenda;
|
|
1107
1121
|
this.agendaMod = agendaMod;
|
|
1108
1122
|
this.db = db;
|
|
1109
1123
|
this.collName = collectionName;
|
|
1124
|
+
this.failedRetentionDays = failedRetentionDays;
|
|
1110
1125
|
this.queues = {
|
|
1111
1126
|
tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
|
|
1112
1127
|
advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
|
|
@@ -1187,9 +1202,45 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1187
1202
|
if (!this.started) {
|
|
1188
1203
|
await this.agenda.start();
|
|
1189
1204
|
this.started = true;
|
|
1205
|
+
this.startFailedJobSweep();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Agenda's auto-remove only fires on success, so failed documents would
|
|
1210
|
+
* otherwise be retained forever. Runs once on worker start, then hourly.
|
|
1211
|
+
*/
|
|
1212
|
+
startFailedJobSweep() {
|
|
1213
|
+
if (this.failedRetentionDays <= 0 || this.sweepTimer) return;
|
|
1214
|
+
void this.sweepFailedJobs();
|
|
1215
|
+
this.sweepTimer = setInterval(() => void this.sweepFailedJobs(), FAILED_SWEEP_INTERVAL_MS);
|
|
1216
|
+
this.sweepTimer.unref?.();
|
|
1217
|
+
}
|
|
1218
|
+
/** Delete failed, fully-retired job documents older than the retention window. */
|
|
1219
|
+
async sweepFailedJobs() {
|
|
1220
|
+
const cutoff = new Date(Date.now() - this.failedRetentionDays * 24 * 3600 * 1e3);
|
|
1221
|
+
try {
|
|
1222
|
+
const res = await this.jobsCollection().deleteMany({
|
|
1223
|
+
failedAt: { $lt: cutoff },
|
|
1224
|
+
// Never touch the repeating tick.
|
|
1225
|
+
repeatInterval: { $in: [null, void 0] },
|
|
1226
|
+
// Leave anything still scheduled for a retry, and anything a worker
|
|
1227
|
+
// currently holds a lock on.
|
|
1228
|
+
$and: [
|
|
1229
|
+
{ $or: [{ nextRunAt: null }, { nextRunAt: { $exists: false } }, { nextRunAt: { $lt: cutoff } }] },
|
|
1230
|
+
{ $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }, { lockedAt: { $lt: cutoff } }] }
|
|
1231
|
+
]
|
|
1232
|
+
});
|
|
1233
|
+
return res.deletedCount ?? 0;
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
console.error("mailery: failed-job sweep failed", err);
|
|
1236
|
+
return 0;
|
|
1190
1237
|
}
|
|
1191
1238
|
}
|
|
1192
1239
|
async stopWorkers() {
|
|
1240
|
+
if (this.sweepTimer) {
|
|
1241
|
+
clearInterval(this.sweepTimer);
|
|
1242
|
+
this.sweepTimer = null;
|
|
1243
|
+
}
|
|
1193
1244
|
if (!this.started) return;
|
|
1194
1245
|
await this.agenda.stop();
|
|
1195
1246
|
this.started = false;
|
|
@@ -1203,6 +1254,15 @@ var AgendaDriver = class _AgendaDriver {
|
|
|
1203
1254
|
await this.stopWorkers();
|
|
1204
1255
|
}
|
|
1205
1256
|
};
|
|
1257
|
+
function collectionFor(prefix) {
|
|
1258
|
+
if (!prefix) return DEFAULT_COLLECTION;
|
|
1259
|
+
if (!/^[A-Za-z0-9_-]+$/.test(prefix)) {
|
|
1260
|
+
throw new Error(
|
|
1261
|
+
`mailery: queue prefix "${prefix}" must contain only letters, digits, '_' or '-' \u2014 it becomes part of a MongoDB collection name.`
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
return `${DEFAULT_COLLECTION}_${prefix}`;
|
|
1265
|
+
}
|
|
1206
1266
|
|
|
1207
1267
|
// src/server/queues/noop.ts
|
|
1208
1268
|
function noopQueueAPI() {
|
|
@@ -1239,7 +1299,9 @@ async function createQueueDriver(config, fallbackDb) {
|
|
|
1239
1299
|
db: config.db ?? fallbackDb,
|
|
1240
1300
|
processEverySeconds: config.processEverySeconds,
|
|
1241
1301
|
lockLifetimeSeconds: config.lockLifetimeSeconds,
|
|
1242
|
-
collectionName: config.collectionName
|
|
1302
|
+
collectionName: config.collectionName,
|
|
1303
|
+
prefix: config.prefix,
|
|
1304
|
+
failedJobRetentionDays: config.failedJobRetentionDays
|
|
1243
1305
|
});
|
|
1244
1306
|
case "noop":
|
|
1245
1307
|
return new NoopDriver();
|
|
@@ -3995,7 +4057,8 @@ var Mailer = class _Mailer {
|
|
|
3995
4057
|
* MAILER_MONGODB_URI — Mongo connection string (required)
|
|
3996
4058
|
* MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
|
|
3997
4059
|
* MAILER_REDIS_URL — Redis connection URL (required)
|
|
3998
|
-
* MAILER_QUEUE_PREFIX —
|
|
4060
|
+
* MAILER_QUEUE_PREFIX — namespaces this instance (optional). Bull: Redis key
|
|
4061
|
+
* prefix. Agenda: jobs collection suffix.
|
|
3999
4062
|
* MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
|
|
4000
4063
|
* MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
|
|
4001
4064
|
* MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
|
|
@@ -4044,7 +4107,7 @@ var Mailer = class _Mailer {
|
|
|
4044
4107
|
}
|
|
4045
4108
|
const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
|
|
4046
4109
|
const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
|
|
4047
|
-
const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
4110
|
+
const queue = driverEnv === "agenda" ? { driver: "agenda", prefix: env.MAILER_QUEUE_PREFIX } : driverEnv === "noop" ? { driver: "noop" } : {
|
|
4048
4111
|
driver: "bull",
|
|
4049
4112
|
redis: { url: required("MAILER_REDIS_URL") },
|
|
4050
4113
|
prefix: env.MAILER_QUEUE_PREFIX
|
|
@@ -4709,6 +4772,40 @@ ${input.plainText}`);
|
|
|
4709
4772
|
});
|
|
4710
4773
|
}
|
|
4711
4774
|
}
|
|
4775
|
+
const offDomain = findOffDomainLinkHosts(input.html, input.fromEmail);
|
|
4776
|
+
if (offDomain.majority && offDomain.hosts.length > 0) {
|
|
4777
|
+
issues.push({
|
|
4778
|
+
rule: "offdomain_links",
|
|
4779
|
+
severity: "warning",
|
|
4780
|
+
message: `Most links point away from the From domain: ${offDomain.hosts.join(", ")}.`,
|
|
4781
|
+
hint: "Link domains that match the sending domain build reputation. Route links through your own domain (or a tracking subdomain of it) where you can."
|
|
4782
|
+
});
|
|
4783
|
+
}
|
|
4784
|
+
const insecure = findInsecureLinkHosts(input.html);
|
|
4785
|
+
if (insecure.length > 0) {
|
|
4786
|
+
issues.push({
|
|
4787
|
+
rule: "insecure_link",
|
|
4788
|
+
severity: "warning",
|
|
4789
|
+
message: `Link uses plain http://: ${insecure.join(", ")}.`,
|
|
4790
|
+
hint: "Mixed-content links get rewritten or warned about by some clients, and http:// correlates with stale spam templates. Use https://."
|
|
4791
|
+
});
|
|
4792
|
+
}
|
|
4793
|
+
if (countImagesMissingAlt(input.html) > 0) {
|
|
4794
|
+
issues.push({
|
|
4795
|
+
rule: "image_missing_alt",
|
|
4796
|
+
severity: "warning",
|
|
4797
|
+
message: "One or more images have no alt text.",
|
|
4798
|
+
hint: "Most clients block images by default on first open. Alt text is what the recipient actually sees, and screen readers need it."
|
|
4799
|
+
});
|
|
4800
|
+
}
|
|
4801
|
+
if (hasImageOnlyLink(input.html)) {
|
|
4802
|
+
issues.push({
|
|
4803
|
+
rule: "image_only_link",
|
|
4804
|
+
severity: "warning",
|
|
4805
|
+
message: "A link wraps an image with no accompanying text.",
|
|
4806
|
+
hint: "With images blocked, an image-only call-to-action is invisible and unclickable. Add a text label inside the link."
|
|
4807
|
+
});
|
|
4808
|
+
}
|
|
4712
4809
|
const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
|
|
4713
4810
|
if (linkCount > 10) {
|
|
4714
4811
|
issues.push({
|
|
@@ -4763,6 +4860,63 @@ function hasBareUrlInVisibleText(html) {
|
|
|
4763
4860
|
const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
|
|
4764
4861
|
return /https?:\/\/[^\s<>"']+/i.test(stripped);
|
|
4765
4862
|
}
|
|
4863
|
+
function findOffDomainLinkHosts(html, fromEmail) {
|
|
4864
|
+
const fromDomain = fromEmail.split("@")[1]?.toLowerCase();
|
|
4865
|
+
if (!fromDomain) return { hosts: [], majority: false };
|
|
4866
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
4867
|
+
let total = 0;
|
|
4868
|
+
let off = 0;
|
|
4869
|
+
for (const href of extractHrefs(html)) {
|
|
4870
|
+
if (href.includes("{{")) continue;
|
|
4871
|
+
const host = hostnameOf(href);
|
|
4872
|
+
if (!host) continue;
|
|
4873
|
+
total++;
|
|
4874
|
+
if (sameSite(host, fromDomain)) continue;
|
|
4875
|
+
off++;
|
|
4876
|
+
hosts.add(host);
|
|
4877
|
+
}
|
|
4878
|
+
return { hosts: Array.from(hosts), majority: total > 0 && off * 2 > total };
|
|
4879
|
+
}
|
|
4880
|
+
function sameSite(a, b) {
|
|
4881
|
+
if (a === b) return true;
|
|
4882
|
+
if (a.endsWith(`.${b}`) || b.endsWith(`.${a}`)) return true;
|
|
4883
|
+
return lastLabels(a) === lastLabels(b);
|
|
4884
|
+
}
|
|
4885
|
+
function lastLabels(host) {
|
|
4886
|
+
return host.split(".").slice(-2).join(".");
|
|
4887
|
+
}
|
|
4888
|
+
function findInsecureLinkHosts(html) {
|
|
4889
|
+
const hits = /* @__PURE__ */ new Set();
|
|
4890
|
+
for (const href of extractHrefs(html)) {
|
|
4891
|
+
if (!/^http:\/\//i.test(href)) continue;
|
|
4892
|
+
const host = hostnameOf(href);
|
|
4893
|
+
if (host) hits.add(host);
|
|
4894
|
+
}
|
|
4895
|
+
return Array.from(hits);
|
|
4896
|
+
}
|
|
4897
|
+
function countImagesMissingAlt(html) {
|
|
4898
|
+
let missing = 0;
|
|
4899
|
+
const re = /<img\b[^>]*>/gi;
|
|
4900
|
+
let m;
|
|
4901
|
+
while (m = re.exec(html)) {
|
|
4902
|
+
const tag = m[0];
|
|
4903
|
+
const alt = /\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
4904
|
+
const value = alt ? alt[1] ?? alt[2] ?? alt[3] ?? "" : "";
|
|
4905
|
+
if (value.trim().length === 0) missing++;
|
|
4906
|
+
}
|
|
4907
|
+
return missing;
|
|
4908
|
+
}
|
|
4909
|
+
function hasImageOnlyLink(html) {
|
|
4910
|
+
const re = /<a\b[^>]*>(.*?)<\/a>/gis;
|
|
4911
|
+
let m;
|
|
4912
|
+
while (m = re.exec(html)) {
|
|
4913
|
+
const inner = m[1];
|
|
4914
|
+
if (!/<img\b/i.test(inner)) continue;
|
|
4915
|
+
const text = inner.replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
|
4916
|
+
if (text.length === 0) return true;
|
|
4917
|
+
}
|
|
4918
|
+
return false;
|
|
4919
|
+
}
|
|
4766
4920
|
function findSpamSignals(text) {
|
|
4767
4921
|
const out = /* @__PURE__ */ new Set();
|
|
4768
4922
|
for (const { pattern, phrase } of SPAM_PHRASES) {
|
|
@@ -7626,7 +7780,7 @@ var DEDUPE_POLICIES = [
|
|
|
7626
7780
|
];
|
|
7627
7781
|
|
|
7628
7782
|
// src/server/index.ts
|
|
7629
|
-
var VERSION = "0.
|
|
7783
|
+
var VERSION = "0.12.1" ;
|
|
7630
7784
|
|
|
7631
7785
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
7632
7786
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|