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.
@@ -623,6 +623,15 @@ interface DnsblConfig {
623
623
  dedicatedIps?: string[];
624
624
  /** Hours between automatic runs. Default 24. Set to 0 to disable scheduled runs. */
625
625
  intervalHours?: number;
626
+ /**
627
+ * Spamhaus Data Query Service key (free at spamhaus.com for low volume).
628
+ * Spamhaus refuses queries that arrive through public or shared resolvers —
629
+ * Google, Cloudflare, and cloud VPC resolvers such as AWS's — answering
630
+ * 127.255.255.254 instead of a verdict. With a key, every `*.spamhaus.org`
631
+ * list is queried as `<key>.<zone>.dq.spamhaus.net`, which answers from
632
+ * anywhere. Rows and the UI keep the public list name; the key is never stored.
633
+ */
634
+ spamhausDqsKey?: string;
626
635
  }
627
636
  interface MailTesterConfig {
628
637
  /** API key from your Mail-Tester paid plan. */
@@ -944,7 +953,7 @@ interface MailerConfig {
944
953
  getAdminActor?: (req: any) => string;
945
954
  onCircuitBreakerTrip?: (info: {
946
955
  reason: string;
947
- rates: Record<string, number>;
956
+ rates: Record<string, number | null>;
948
957
  }) => Promise<void> | void;
949
958
  onSendFailure?: (info: {
950
959
  send: any;
@@ -1424,11 +1433,15 @@ interface HealthDoc {
1424
1433
  complained: number;
1425
1434
  failedToSend: number;
1426
1435
  };
1436
+ /**
1437
+ * Bounce/complaint rates are per send in the window; failureRate is failed
1438
+ * attempts over all attempts. Null when the window has no denominator yet.
1439
+ */
1427
1440
  rates: {
1428
- bounceRate: number;
1429
- hardBounceRate: number;
1430
- complaintRate: number;
1431
- failureRate: number;
1441
+ bounceRate: number | null;
1442
+ hardBounceRate: number | null;
1443
+ complaintRate: number | null;
1444
+ failureRate: number | null;
1432
1445
  };
1433
1446
  status: HealthStatus;
1434
1447
  trippedAt: Date | null;
@@ -623,6 +623,15 @@ interface DnsblConfig {
623
623
  dedicatedIps?: string[];
624
624
  /** Hours between automatic runs. Default 24. Set to 0 to disable scheduled runs. */
625
625
  intervalHours?: number;
626
+ /**
627
+ * Spamhaus Data Query Service key (free at spamhaus.com for low volume).
628
+ * Spamhaus refuses queries that arrive through public or shared resolvers —
629
+ * Google, Cloudflare, and cloud VPC resolvers such as AWS's — answering
630
+ * 127.255.255.254 instead of a verdict. With a key, every `*.spamhaus.org`
631
+ * list is queried as `<key>.<zone>.dq.spamhaus.net`, which answers from
632
+ * anywhere. Rows and the UI keep the public list name; the key is never stored.
633
+ */
634
+ spamhausDqsKey?: string;
626
635
  }
627
636
  interface MailTesterConfig {
628
637
  /** API key from your Mail-Tester paid plan. */
@@ -944,7 +953,7 @@ interface MailerConfig {
944
953
  getAdminActor?: (req: any) => string;
945
954
  onCircuitBreakerTrip?: (info: {
946
955
  reason: string;
947
- rates: Record<string, number>;
956
+ rates: Record<string, number | null>;
948
957
  }) => Promise<void> | void;
949
958
  onSendFailure?: (info: {
950
959
  send: any;
@@ -1424,11 +1433,15 @@ interface HealthDoc {
1424
1433
  complained: number;
1425
1434
  failedToSend: number;
1426
1435
  };
1436
+ /**
1437
+ * Bounce/complaint rates are per send in the window; failureRate is failed
1438
+ * attempts over all attempts. Null when the window has no denominator yet.
1439
+ */
1427
1440
  rates: {
1428
- bounceRate: number;
1429
- hardBounceRate: number;
1430
- complaintRate: number;
1431
- failureRate: number;
1441
+ bounceRate: number | null;
1442
+ hardBounceRate: number | null;
1443
+ complaintRate: number | null;
1444
+ failureRate: number | null;
1432
1445
  };
1433
1446
  status: HealthStatus;
1434
1447
  trippedAt: Date | null;
package/dist/testing.cjs CHANGED
@@ -15416,11 +15416,11 @@ var ZERO_COUNTERS = {
15416
15416
  complained: 0,
15417
15417
  failedToSend: 0
15418
15418
  };
15419
- var ZERO_RATES = {
15420
- bounceRate: 0,
15421
- hardBounceRate: 0,
15422
- complaintRate: 0,
15423
- failureRate: 0
15419
+ var EMPTY_RATES = {
15420
+ bounceRate: null,
15421
+ hardBounceRate: null,
15422
+ complaintRate: null,
15423
+ failureRate: null
15424
15424
  };
15425
15425
  async function recordHealthCounter(ctx, counter2, dims, by = 1) {
15426
15426
  const windowMs = ctx.config.circuitBreaker.windowMinutes * 60 * 1e3;
@@ -15450,7 +15450,7 @@ async function upsertCounter(ctx, _id, senderDomain, kind, counter2, by, windowM
15450
15450
  trippedAt: null,
15451
15451
  trippedReason: null,
15452
15452
  manuallyResumedAt: null,
15453
- rates: { ...ZERO_RATES }
15453
+ rates: { ...EMPTY_RATES }
15454
15454
  },
15455
15455
  $set: { updatedAt: /* @__PURE__ */ new Date() }
15456
15456
  },
@@ -15477,7 +15477,7 @@ async function evaluateHealth(ctx) {
15477
15477
  windowStartedAt: /* @__PURE__ */ new Date(),
15478
15478
  windowDurationMs: windowMs,
15479
15479
  counters: { ...ZERO_COUNTERS },
15480
- rates: { ...ZERO_RATES },
15480
+ rates: { ...EMPTY_RATES },
15481
15481
  status: "healthy",
15482
15482
  updatedAt: /* @__PURE__ */ new Date()
15483
15483
  }
@@ -15485,13 +15485,21 @@ async function evaluateHealth(ctx) {
15485
15485
  );
15486
15486
  continue;
15487
15487
  }
15488
- const c = doc.counters;
15489
- const total = c.sent || 1;
15488
+ const c = { ...ZERO_COUNTERS, ...doc.counters };
15489
+ const perSent = (n) => c.sent > 0 ? n / c.sent : null;
15490
+ const attempts = c.sent + c.failedToSend;
15490
15491
  const rates = {
15491
- bounceRate: c.bounced / total,
15492
- hardBounceRate: c.hardBounced / total,
15493
- complaintRate: c.complained / total,
15494
- failureRate: c.failedToSend / total
15492
+ bounceRate: perSent(c.bounced),
15493
+ hardBounceRate: perSent(c.hardBounced),
15494
+ complaintRate: perSent(c.complained),
15495
+ // Failed attempts over all attempts, so it stays within 0–100%.
15496
+ failureRate: attempts > 0 ? c.failedToSend / attempts : null
15497
+ };
15498
+ const pct2 = {
15499
+ bounce: (rates.bounceRate ?? 0) * 100,
15500
+ hardBounce: (rates.hardBounceRate ?? 0) * 100,
15501
+ complaint: (rates.complaintRate ?? 0) * 100,
15502
+ failure: (rates.failureRate ?? 0) * 100
15495
15503
  };
15496
15504
  await ctx.collections.health.updateOne(
15497
15505
  { _id: doc._id },
@@ -15501,12 +15509,12 @@ async function evaluateHealth(ctx) {
15501
15509
  if (c.sent < cb.minSendsBeforeEval) continue;
15502
15510
  if (doc.status === "tripped") continue;
15503
15511
  let trippedReason = null;
15504
- if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
15505
- trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15506
- } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
15507
- trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15508
- } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
15509
- trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15512
+ if (pct2.hardBounce >= cb.hardBounceRatePctTrip) {
15513
+ trippedReason = `hard bounce rate ${pct2.hardBounce.toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
15514
+ } else if (pct2.complaint >= cb.complaintRatePctTrip) {
15515
+ trippedReason = `complaint rate ${pct2.complaint.toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
15516
+ } else if (pct2.bounce >= cb.combinedBounceRatePctTrip) {
15517
+ trippedReason = `combined bounce rate ${pct2.bounce.toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
15510
15518
  }
15511
15519
  if (trippedReason) {
15512
15520
  const result = await ctx.collections.health.updateOne(
@@ -15541,7 +15549,7 @@ async function evaluateHealth(ctx) {
15541
15549
  }
15542
15550
  continue;
15543
15551
  }
15544
- if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
15552
+ if (pct2.failure >= cb.failedToSendRatePctDegrade) {
15545
15553
  if (doc.status !== "degraded") {
15546
15554
  await ctx.collections.health.updateOne(
15547
15555
  { _id: doc._id },
@@ -16444,6 +16452,10 @@ async function dispatchSend(sendId, ctx) {
16444
16452
  await markFailed(send._id, "contact_missing", ctx);
16445
16453
  return;
16446
16454
  }
16455
+ if (!send.emailAtSend?.trim() || !contact.email?.trim()) {
16456
+ await markFailed(send._id, "no_recipient: the contact has no email address", ctx);
16457
+ return;
16458
+ }
16447
16459
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
16448
16460
  if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
16449
16461
  await ctx.collections.sends.updateOne(
@@ -16640,6 +16652,10 @@ async function processOneRunStep(runId, ctx) {
16640
16652
  await exitFlowRun(run, "contact_missing", ctx);
16641
16653
  return;
16642
16654
  }
16655
+ if (!contact.email?.trim()) {
16656
+ await exitFlowRun(run, "contact_no_email", ctx);
16657
+ return;
16658
+ }
16643
16659
  const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
16644
16660
  if (sub && sub.status !== "subscribed") {
16645
16661
  await exitFlowRun(run, sub.status, ctx);
@@ -17508,7 +17524,7 @@ async function runDnsblChecks(ctx, opts = {}) {
17508
17524
  }
17509
17525
  let listedCount = 0;
17510
17526
  async function processPair(p) {
17511
- const queryName = buildQueryName(p.target, p.targetKind, p.list.host);
17527
+ const queryName = buildQueryName(p.target, p.targetKind, spamhausQueryHost(p.list.host, cfg.spamhausDqsKey));
17512
17528
  const lookup2 = queryName ? await queryDnsbl(resolver, queryName) : { result: "error", returnCodes: [], errorMessage: "unsupported target format" };
17513
17529
  if (lookup2.transient) return;
17514
17530
  if (lookup2.result === "listed") listedCount++;
@@ -17555,6 +17571,10 @@ function collectTargets(ctx, cfg) {
17555
17571
  ips: (cfg.dedicatedIps ?? []).filter((ip) => net__default.default.isIP(ip) !== 0)
17556
17572
  };
17557
17573
  }
17574
+ function spamhausQueryHost(listHost, dqsKey) {
17575
+ const zone = dqsKey ? /^([a-z0-9-]+)\.spamhaus\.org$/i.exec(listHost)?.[1] : void 0;
17576
+ return zone ? `${dqsKey}.${zone}.dq.spamhaus.net` : listHost;
17577
+ }
17558
17578
  function buildQueryName(target, kind, listHost) {
17559
17579
  if (kind === "ip") {
17560
17580
  const v = net__default.default.isIP(target);
@@ -17590,16 +17610,22 @@ async function queryDnsbl(resolver, query) {
17590
17610
  };
17591
17611
  }
17592
17612
  }
17613
+ var REFUSAL_REASONS = {
17614
+ "127.255.255.252": "malformed query name for this list",
17615
+ "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",
17616
+ "127.255.255.255": "this resolver has exceeded the free query limit"
17617
+ };
17593
17618
  function interpretRecords(records) {
17594
17619
  if (!records || records.length === 0) {
17595
17620
  return { result: "clean", returnCodes: [], errorMessage: null };
17596
17621
  }
17597
17622
  const errorish = records.filter((r) => r.startsWith("127.255.255."));
17598
17623
  if (errorish.length === records.length) {
17624
+ const reasons = [...new Set(records.map((r) => REFUSAL_REASONS[r] ?? "unrecognised refusal code"))];
17599
17625
  return {
17600
17626
  result: "error",
17601
17627
  returnCodes: records,
17602
- errorMessage: `list returned reserved code(s): ${records.join(", ")}`
17628
+ errorMessage: `not a listing \u2014 the list refused the query (${records.join(", ")}): ${reasons.join("; ")}`
17603
17629
  };
17604
17630
  }
17605
17631
  const listed = records.filter((r) => r.startsWith("127.") && !r.startsWith("127.255.255."));