Skip to content

fix: classify connect-stage SMTP errors with bounce rules - #497

Merged
andris9 merged 1 commit into
masterfrom
fix/connect-stage-bounce-rules
Jul 17, 2026
Merged

fix: classify connect-stage SMTP errors with bounce rules#497
andris9 merged 1 commit into
masterfrom
fix/connect-stage-bounce-rules

Conversation

@andris9

@andris9 andris9 commented Jul 17, 2026

Copy link
Copy Markdown
Member

Follow-up to #496, which correctly diagnosed a real bug but fixed it one layer too high.

The problem

_onError preset err.category = 'network' on every connect-stage failure. handleResponseError short-circuits on a dns/network/policy category, so bounces.check() has never run for connect-stage errors — the whole ruleset has been dead there, including the rule that names the exact response from #496 verbatim:

# 554 5.7.1 You are not allowed to connect
^554[ \-].* You are not allowed to connect,defer,blacklist,Sender IP blacklisted

That also explains both of the "catches" in #496: no err.category fitted because the category was never supposed to be chosen in code, and the IP auto-switch "didn't work at this stage" because category: 'blacklist' is exactly what drives it.

The fix

Leave permanent (5xx) connect failures uncategorized so the rules classify them. config/bounces.txt already ends in:

^4\d\d,defer,other,Other temporary error
^5\d\d,reject,other,Other permanent error

so an unrecognized 5xx still rejects per RFC 5321 §4.2.1 — #496's goal, kept — without hardcoding that policy in JS, while the 85 blacklist rules above it apply and rotate the source IP via disabledAddresses.

Transient failures keep category: 'network' and defer as before. That boundary is deliberate: the 4xx rules answer for a response to an envelope, where 452 over quota is final. At connect time it isn't, and 7 of 8 realistic greeting-stage 4xx responses would flip action if routed through them.

Net effect at greeting:

response before after
554 ... You are not allowed to connect defer ×17 over 48h on the same IP, then bounce defer + blacklist that IP for the domain → next attempt uses another pool IP → reject once the pool is exhausted
5xx matching no specific rule same dead-end deferral reject (rule 571)
4xx / socket error / timeout defer unchanged

Also reverts #496's handleResponseError hunk: nodemailer already sets err.responseCode in _formatError before _onError runs, nothing downstream read the backfilled value, and the rewritten condition was equivalent to the original. The net production diff vs 3.10.17 is one hunk in _onError.

Verified

  • Drove the real handleResponseError: 554 ... not allowed to connectDEFERRED[blacklist] with category: blacklist + address set, passing the sending-zone.js:350 guard → IP rotates. Unrecognized 5xx → REJECTED[other]. 421DEFERRED[network].
  • Drove a fake MX over a real socket to confirm nodemailer populates err.responseCode/err.response from the banner before _onError sees it.
  • New test/bounces-test.js (first coverage for bounces.check) pins the three rules this routing depends on. Suite + eslint green.

Notes

  • Blast radius is wider than the greeting: _onError also covers HELO/LHLO, EHLO-with-requireTLS and STARTTLS, so a 5xx there routes through the rules too. Consistent with the intent.
  • Under enforceTLS the banner arrives before STARTTLS, so an on-path attacker can inject a 5xx that blacklists a pool IP for that domain for blacklist.ttl, where before it became policy/reject. Bounded and per-domain (DomainConfig.get deep-clones, so shared defaults can't be poisoned), and such an attacker can already fail the delivery outright — noting it so the tradeoff is deliberate.

Thanks @dragoangel for the diagnosis and the production trace — the bug and its cause were both yours.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GFF2LP1xQR2yV9DB37wPyD

_onError() preset err.category='network' on every connect-stage failure, which
makes handleResponseError() take the preset-category branch and never call
bounces.check(). The bounce ruleset has therefore been dead at connect stage,
including the rule that names this exact response:

    # 554 5.7.1 You are not allowed to connect
    ^554[ \-].* You are not allowed to connect,defer,blacklist,Sender IP blacklisted

Leave permanent (5xx) connect failures uncategorized so the rules classify them.
bounces.txt already ends in "^5\d\d,reject,other", so an unrecognized 5xx still
rejects per RFC 5321 4.2.1 without hardcoding that policy in JS, while the
specific rules above it apply -- including the 85 blacklist rules, which feed
disabledAddresses and rotate the source IP for the domain.

Transient failures keep category='network' and defer exactly as before. The 4xx
rules in bounces.txt answer for a response to an envelope, where "452 over quota"
is a final verdict; at connect time it is not one.

Refines #496, which routed every connect-stage 5xx to a hardcoded reject and so
skipped the blacklist rules, reimplementing the ruleset's own terminal rule in JS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFF2LP1xQR2yV9DB37wPyD
@andris9
andris9 merged commit 6710141 into master Jul 17, 2026
8 checks passed
@andris9
andris9 deleted the fix/connect-stage-bounce-rules branch July 17, 2026 12:58
Comment thread lib/sender.js
status: false
};
} else if (!err.responseCode) {
} else if (!err.responseCode && !/^\d{3}\b/.test(err.response || err.message)) {

@dragoangel dragoangel Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andris9 Hi, wanted to understand just for myself, is there any way we can get err.response || err.message with code and not get err.responseCode? Also err.message seems like usually have more details - more human readable thing - not original response from upstream/etc :)

@dragoangel

Copy link
Copy Markdown
Contributor

Thanks for getting this in right direction 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants