mailery 0.18.0 → 0.19.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 CHANGED
@@ -1717,11 +1717,11 @@ var ZERO_COUNTERS = {
1717
1717
  complained: 0,
1718
1718
  failedToSend: 0
1719
1719
  };
1720
- var ZERO_RATES = {
1721
- bounceRate: 0,
1722
- hardBounceRate: 0,
1723
- complaintRate: 0,
1724
- failureRate: 0
1720
+ var EMPTY_RATES = {
1721
+ bounceRate: null,
1722
+ hardBounceRate: null,
1723
+ complaintRate: null,
1724
+ failureRate: null
1725
1725
  };
1726
1726
  async function recordHealthCounter(ctx, counter2, dims, by = 1) {
1727
1727
  const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
@@ -1751,7 +1751,7 @@ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowM
1751
1751
  trippedAt: null,
1752
1752
  trippedReason: null,
1753
1753
  manuallyResumedAt: null,
1754
- rates: { ...ZERO_RATES }
1754
+ rates: { ...EMPTY_RATES }
1755
1755
  },
1756
1756
  $set: { updatedAt: /* @__PURE__ */ new Date() }
1757
1757
  },
@@ -1778,7 +1778,7 @@ async function evaluateHealth(ctx) {
1778
1778
  windowStartedAt: /* @__PURE__ */ new Date(),
1779
1779
  windowDurationMs: windowMs,
1780
1780
  counters: { ...ZERO_COUNTERS },
1781
- rates: { ...ZERO_RATES },
1781
+ rates: { ...EMPTY_RATES },
1782
1782
  status: "healthy",
1783
1783
  updatedAt: /* @__PURE__ */ new Date()
1784
1784
  }
@@ -1786,13 +1786,21 @@ async function evaluateHealth(ctx) {
1786
1786
  );
1787
1787
  continue;
1788
1788
  }
1789
- const c = doc.counters;
1790
- const total = c.sent || 1;
1789
+ const c = { ...ZERO_COUNTERS, ...doc.counters };
1790
+ const perSent = (n) => c.sent > 0 ? n / c.sent : null;
1791
+ const attempts = c.sent + c.failedToSend;
1791
1792
  const rates = {
1792
- bounceRate: c.bounced / total,
1793
- hardBounceRate: c.hardBounced / total,
1794
- complaintRate: c.complained / total,
1795
- failureRate: c.failedToSend / total
1793
+ bounceRate: perSent(c.bounced),
1794
+ hardBounceRate: perSent(c.hardBounced),
1795
+ complaintRate: perSent(c.complained),
1796
+ // Failed attempts over all attempts, so it stays within 0–100%.
1797
+ failureRate: attempts > 0 ? c.failedToSend / attempts : null
1798
+ };
1799
+ const pct2 = {
1800
+ bounce: (rates.bounceRate ?? 0) * 100,
1801
+ hardBounce: (rates.hardBounceRate ?? 0) * 100,
1802
+ complaint: (rates.complaintRate ?? 0) * 100,
1803
+ failure: (rates.failureRate ?? 0) * 100
1796
1804
  };
1797
1805
  await ctx.collections.health.updateOne(
1798
1806
  { _id: doc._id },
@@ -1802,12 +1810,12 @@ async function evaluateHealth(ctx) {
1802
1810
  if (c.sent < cb.minSendsBeforeEval) continue;
1803
1811
  if (doc.status === "tripped") continue;
1804
1812
  let trippedReason = null;
1805
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
1806
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
1807
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
1808
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
1809
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
1810
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
1813
+ if (pct2.hardBounce >= cb.hardBounceRatePctTrip) {
1814
+ trippedReason = `hard bounce rate ${pct2.hardBounce.toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
1815
+ } else if (pct2.complaint >= cb.complaintRatePctTrip) {
1816
+ trippedReason = `complaint rate ${pct2.complaint.toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
1817
+ } else if (pct2.bounce >= cb.combinedBounceRatePctTrip) {
1818
+ trippedReason = `combined bounce rate ${pct2.bounce.toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
1811
1819
  }
1812
1820
  if (trippedReason) {
1813
1821
  const result = await ctx.collections.health.updateOne(
@@ -1842,7 +1850,7 @@ async function evaluateHealth(ctx) {
1842
1850
  }
1843
1851
  continue;
1844
1852
  }
1845
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
1853
+ if (pct2.failure >= cb.failedToSendRatePctDegrade) {
1846
1854
  if (doc.status !== "degraded") {
1847
1855
  await ctx.collections.health.updateOne(
1848
1856
  { _id: doc._id },
@@ -2804,6 +2812,10 @@ async function dispatchSend(sendId, ctx) {
2804
2812
  await markFailed(send._id, "contact_missing", ctx);
2805
2813
  return;
2806
2814
  }
2815
+ if (!send.emailAtSend?.trim() || !contact.email?.trim()) {
2816
+ await markFailed(send._id, "no_recipient: the contact has no email address", ctx);
2817
+ return;
2818
+ }
2807
2819
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
2808
2820
  if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
2809
2821
  await ctx.collections.sends.updateOne(
@@ -3000,6 +3012,10 @@ async function processOneRunStep(runId, ctx) {
3000
3012
  await exitFlowRun(run, "contact_missing", ctx);
3001
3013
  return;
3002
3014
  }
3015
+ if (!contact.email?.trim()) {
3016
+ await exitFlowRun(run, "contact_no_email", ctx);
3017
+ return;
3018
+ }
3003
3019
  const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
3004
3020
  if (sub && sub.status !== "subscribed") {
3005
3021
  await exitFlowRun(run, sub.status, ctx);
@@ -3895,8 +3911,8 @@ async function runDnsblChecks(ctx, opts = {}) {
3895
3911
  }
3896
3912
  let listedCount = 0;
3897
3913
  async function processPair(p) {
3898
- const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
3899
- const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3914
+ const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
3915
+ const lookup2 = queryName ? await queryDnsbl(resolver, queryName, p.list.host) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3900
3916
  if (lookup2.transient) return;
3901
3917
  if (lookup2.result === "listed") listedCount++;
3902
3918
  await ctx.collections.dnsblChecks.updateOne(
@@ -3942,6 +3958,10 @@ function collectTargets(ctx, cfg) {
3942
3958
  ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
3943
3959
  };
3944
3960
  }
3961
+ function spamhausQueryHost(listHost, dqsKey) {
3962
+ const zone = dqsKey ? /^([a-z0-9-]+)\.spamhaus\.org$/i.exec(listHost)?.[1] : void 0;
3963
+ return zone ? `${dqsKey}.${zone}.dq.spamhaus.net` : listHost;
3964
+ }
3945
3965
  function buildQueryName(target, kind, listHost) {
3946
3966
  if (kind === "ip") {
3947
3967
  const v = net__default.default.isIP(target);
@@ -3953,10 +3973,10 @@ function buildQueryName(target, kind, listHost) {
3953
3973
  return `${domain}.${listHost}`;
3954
3974
  }
3955
3975
  var TRANSIENT_DNS_CODES = /* @__PURE__ */ new Set(["ESERVFAIL", "EREFUSED", "ETIMEOUT", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED"]);
3956
- async function queryDnsbl(resolver, query) {
3976
+ async function queryDnsbl(resolver, query, listHost) {
3957
3977
  try {
3958
3978
  const records = await resolver.resolve4(query);
3959
- return interpretRecords(records);
3979
+ return interpretRecords(records, listHost);
3960
3980
  } catch (err) {
3961
3981
  const code = err?.code;
3962
3982
  if (code === "ENOTFOUND" || code === "ENODATA") {
@@ -3977,16 +3997,29 @@ async function queryDnsbl(resolver, query) {
3977
3997
  };
3978
3998
  }
3979
3999
  }
3980
- function interpretRecords(records) {
4000
+ var REFUSAL_REASONS = {
4001
+ "127.255.255.252": "malformed query name for this list",
4002
+ "127.255.255.254": "queried through a public or shared DNS resolver, which Spamhaus blocks. Set dnsbl.spamhausDqsKey (free Spamhaus DQS key) or use a private resolver",
4003
+ "127.255.255.255": "this resolver has exceeded the free query limit"
4004
+ };
4005
+ function interpretRecords(records, listHost) {
3981
4006
  if (!records || records.length === 0) {
3982
4007
  return { result: "clean", returnCodes: [], errorMessage: null };
3983
4008
  }
4009
+ if (listHost && REGISTRABLE_ONLY_LISTS.has(listHost) && records.every((r) => r === "127.0.0.1")) {
4010
+ return {
4011
+ result: "error",
4012
+ returnCodes: records,
4013
+ errorMessage: `${listHost} refused the query (127.0.0.1): queried through a public or shared DNS resolver, or over the free query limit. Use a private resolver`
4014
+ };
4015
+ }
3984
4016
  const errorish = records.filter((r) => r.startsWith("127.255.255."));
3985
4017
  if (errorish.length === records.length) {
4018
+ const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
3986
4019
  return {
3987
4020
  result: "error",
3988
4021
  returnCodes: records,
3989
- errorMessage: `list returned reserved code(s): ${records.join(", ")}`
4022
+ errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
3990
4023
  };
3991
4024
  }
3992
4025
  const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
@@ -6436,7 +6469,7 @@ async function checkDnsbl(mailer) {
6436
6469
  label: "DNS block-list checks",
6437
6470
  severity: "warn",
6438
6471
  message: `all ${docs.length} checks errored`,
6439
- hint: "Lookups against block lists are failing \u2014 usually a DNS-resolution problem on the host running mailery, or a list rate-limiting your public resolver. Verify outbound DNS works."
6472
+ hint: "Lookups against block lists are failing \u2014 usually a DNS-resolution problem on the host running mailery, or a list refusing your public resolver (Spamhaus answers 127.255.255.254; set dnsbl.spamhausDqsKey). Verify outbound DNS works."
6440
6473
  };
6441
6474
  }
6442
6475
  return {
@@ -9546,7 +9579,7 @@ function unitToMs2(value, unit) {
9546
9579
  }
9547
9580
 
9548
9581
  // src/server/api/agent.ts
9549
- var VERSION = "0.18.0" ;
9582
+ var VERSION = "0.19.1" ;
9550
9583
  var broadcastCountSchema = zod.z.object({
9551
9584
  recipientCap: zod.z.number().int().positive().max(1e7).nullable().optional()
9552
9585
  });
@@ -11613,7 +11646,7 @@ var DEDUPE_POLICIES = [
11613
11646
  ];
11614
11647
 
11615
11648
  // src/server/index.ts
11616
- var VERSION2 = "0.18.0" ;
11649
+ var VERSION2 = "0.19.1" ;
11617
11650
 
11618
11651
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
11619
11652
  exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;