mailery 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,7 +3911,7 @@ 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);
3914
+ const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
3899
3915
  const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
3900
3916
  if (lookup2.transient) return;
3901
3917
  if (lookup2.result === "listed") listedCount++;
@@ -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);
@@ -3977,16 +3997,22 @@ async function queryDnsbl(resolver, query) {
3977
3997
  };
3978
3998
  }
3979
3999
  }
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
+ };
3980
4005
  function interpretRecords(records) {
3981
4006
  if (!records || records.length === 0) {
3982
4007
  return { result: "clean", returnCodes: [], errorMessage: null };
3983
4008
  }
3984
4009
  const errorish = records.filter((r) => r.startsWith("127.255.255."));
3985
4010
  if (errorish.length === records.length) {
4011
+ const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
3986
4012
  return {
3987
4013
  result: "error",
3988
4014
  returnCodes: records,
3989
- errorMessage: `list returned reserved code(s): ${records.join(", ")}`
4015
+ errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
3990
4016
  };
3991
4017
  }
3992
4018
  const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));
@@ -6436,7 +6462,7 @@ async function checkDnsbl(mailer) {
6436
6462
  label: "DNS block-list checks",
6437
6463
  severity: "warn",
6438
6464
  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."
6465
+ 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
6466
  };
6441
6467
  }
6442
6468
  return {
@@ -9546,7 +9572,7 @@ function unitToMs2(value, unit) {
9546
9572
  }
9547
9573
 
9548
9574
  // src/server/api/agent.ts
9549
- var VERSION = "0.18.0" ;
9575
+ var VERSION = "0.19.0" ;
9550
9576
  var broadcastCountSchema = zod.z.object({
9551
9577
  recipientCap: zod.z.number().int().positive().max(1e7).nullable().optional()
9552
9578
  });
@@ -11613,7 +11639,7 @@ var DEDUPE_POLICIES = [
11613
11639
  ];
11614
11640
 
11615
11641
  // src/server/index.ts
11616
- var VERSION2 = "0.18.0" ;
11642
+ var VERSION2 = "0.19.0" ;
11617
11643
 
11618
11644
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
11619
11645
  exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;