Files
6c7f60c8e8 fix: classify a reply by what it says, not by the rendering of it (#223)
Two fixes in parseSMTPError, both reached by the same question: where
does the reply text come from, and which part of it may be read as a
statement about the recipient.

The keyword check that reads a reply as being about the address --
"user unknown", "does not exist" and eight more -- ran before the switch,
and two things were wrong with that.

It applied to transient replies. A 4xx says the server could not answer
now, whatever its text mentions, so it cannot be read as a statement
about the address; but any 4xx naming the recipient was answered
ErrServerUnavailable, which CheckSMTP reads as evidence that the domain
is not a catch-all. Now confined to 5xx, bounded at both ends:
ParseSMTPError is exported, and a code outside SMTP's classes says
nothing about a recipient either.

Message moves with Details. For a reply code the switch does not name,
parseBasicErr builds both from the same text, so a 501 reports
501 5.7.1 ... where it used to report 501 "5.7.1 ...". Tested, and the
changelog says so. This is a defect by the RFC's
definition of the class rather than an observed one: the only 4xx among
the nineteen providers probed for #222 is Microsoft's 452 4.5.3
Recipients belong to multiple regions, which matches none of the phrases
and already reached the switch.

5xx classification is unchanged. Two things that would have changed it
were tried and dropped, because both turn on the keyword lists being
precise and they are not. Letting a blocklist phrase outrank a recipient
phrase reclassified "recipient address rejected: access denied" as
blocked. That is Postfix's wording for an unknown recipient; it matched
only because our list carries the very broad "denied". Widening the failure bound to >= 400 put
400 through keyword matching, which changed its answer too. The override
of 551 through 554 by recipient phrases is likewise left alone: believing
the code or the text there is a judgement call, not a defect.

Details carried a rendering rather than a reply. textproto.Error formats
with %q, which wraps the text in quotes the server never sent and escapes
the newlines of a multi-line reply into a literal \n; Details is
serialised, so every consumer saw that. replyText reads Code and Msg, the
fields textproto parsed the reply into, and falls back to Error() only
for errors that carry no reply of their own -- dial failures, DNS
failures, and the flattened replies the exported ParseSMTPError also
accepts. Those are unchanged.

The blocklist keywords were two lists. The 550 branch knew "blacklisted"
and "block list" and parseBasicErr did not, so whether a phrase counted
depended on whether the reply code was one the switch happens to name: a
501 mentioning a blocklist was reported verbatim while a 550 was reported
as blocked. One list now, and a test walks it against a code inside the
switch and one outside.

The test #222 added to pin the %q behaviour failed here, which is what it
was for, and is replaced by tests for the behaviour that supersedes it.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-13 11:17:47 +08:00

7.7 KiB

Change log

Unreleased

  • Internal: Lint the whole repository rather than only changed lines, clearing 42 pre-existing findings; enable usestdlibvars and intrange; fix six error assertions that only checked that an error was non-nil #217
  • Fix: Reachable is no, not unknown, when the mail server explicitly refuses the address and DisableCatchAllCheck() is in effect. SMTP.CatchAll was set before the catch-all probe ran and only ever cleared by a 550-class refusal, so with the probe disabled it stayed true and suppressed the verdict the address check had already produced #220
  • Feature: LookupError.EnhancedCode() exposes the RFC 3463 enhanced status code a server sent, for example 5.1.1. The class digit is dependable, the subject digit is not a verdict -- Yandex answers an unknown recipient with the same 5.7.1 that Apple uses to say our sending IP is blocklisted -- and it is empty for the providers that send no code at all #222
  • Fix: LookupError.Details, and Message for a reply code the classifier does not recognise, carry the SMTP reply as it arrived rather than Go's %q rendering of it, which added quotes and escaped the newlines of a multi-line reply. A transient 4xx is no longer read as a permanent statement about the recipient #223

v1.5.0

  • Breaking: Supported Go versions are now the three most recent releases, and go.mod requires the oldest of them, Go 1.25. The versions of golang.org/x/net and golang.org/x/text that fix GO-2026-5026 and GO-2026-5970 require it, and both were reachable from Verify, CheckMX and CheckSMTP through idna.ToASCII -- the second of them an infinite loop on caller-supplied input #212
  • Fix: AddDisposableDomains no longer races the refresh that EnableAutoUpdateDisposable() schedules. Calling it from another goroutine while a refresh was running was a fatal error: concurrent map iteration and map write, which brings the process down and no recover can catch #211
  • Fix: A small allowlist, cmd/build_metadata/disposable_allowlist.txt, keeps real providers the upstream list misclassifies out of the disposable set -- currently hush.com, lavabit.com, mail2world.com, qmail.com, 4x4man.com and alphafrau.de. It applies to the generated list and to every EnableAutoUpdateDisposable() refresh, and since the free list is the free candidates minus the disposable one, these domains are now reported free rather than neither #209
  • Breaking: The generated disposable list now comes from the same source EnableAutoUpdateDisposable() fetches at runtime. The two had diverged, so enabling auto-update used to replace most of the baked-in list; it is now a no-op. IsDisposable gains 37679 domains and loses 96017, the vast majority of which no longer resolve #208
  • Fix: The free-domain list is built from its original sources instead of an aggregate that had begun merging disposable blocklists into itself. IsFreeDomain and SuggestDomain no longer treat throwaway domains as free providers, 108 carrier and portal mailboxes are recognised, and IsFreeDomain("atlanticbb.net") works -- the generated key carried a stray no-break space #207
  • Fix: IsRoleAccount recognises cto, ctos, cfo and cfos. They were added to the source list in December 2025 but the generated map was never rebuilt, so they returned false until now #205
  • Feature: Support a custom DNS resolver for MX and SMTP host lookups via Resolver() #191
  • Fix: Verify keeps the SMTP result when the exchange fails partway. SMTP used to come back nil, which could not distinguish a host that never answered from one that answered and refused the sender #201
  • Fix: Reachable is no, not unknown, when the MX lookup fails with no such host, and Verify returns ErrNoSuchHost #139
  • Fix: Suggestion is filled in for disposable domains, which used to return early before the suggestion was computed #140
  • Breaking: The free-domain sources drop six forks of one 2014 gist for the list they were forked from. One of the six was contributing throwaway domains rather than providers -- 77% of its 23,882 entries appear on disposable blocklists -- so IsFreeDomain loses 2,009 domains it should not have had #169
  • Breaking: LookupError wraps the error it was derived from, reachable via errors.Is/errors.As. ParseSMTPError no longer returns a nil *LookupError for a non-nil input. Adds an unexported field, so whole-struct comparison against a LookupError literal no longer matches #202
  • Breaking: Remove the non-functional Yahoo API verifier; EnableAPIVerifier(YAHOO) and the YAHOO constant are gone #198
  • Fix: Yahoo API test panic no longer aborts the test suite #196

v1.4.1

  • Feature: Configurable timeouts via ConnectTimeout() and OperationTimeout() #110
  • Breaking: Remove the Gmail API verifier; EnableAPIVerifier(GMAIL) and the GMAIL constant are gone #113
  • Update the free domain sources and rebuild the generated metadata #127
  • Update Dependencies

v1.4.0

  • Feature: Support Gmail&Yahoo SMTP check by API #88
  • Optimization: Return HasMXRecord as true when at least one valid mx records exist #94
  • Update Dependencies

v1.3.3

  • Making catchAll detection optional #76
  • When the user enables EnableAutoUpdateDisposable(), the disposable domains configuration is updated once by default.
  • Update test cases
  • Update Dependencies

v1.3.2

  • Uses x/net/proxy to fix issue when using SOCKS5

v1.3.1

  • Fix a bug: IsDisposable() matches the complete email domain
  • Update dependent metadata
  • Update Dependencies

v1.3.0

  • Support setting SOCKS5 proxy to perform CheckSMTP()
  • Make pkg compatible with earlier versions of Go

v1.2.0

  • Support adding custom disposable email domains
  • Fix a wrong reference in README
  • Update dependent metadata
  • Update Dependencies

v1.1.0

  • Performance optimization:
    • reduce Result struct size from 96 to 80
    • ParseAddress() return Syntax instead of reference, for reducing GC pressure and improve memory locality.
  • Provide a simple API server
  • Bugfix: gravatar images may not exist

v1.0.3

  • Add a New feature: domain suggestion (typo check)

v1.0.2

  • Add build metadata tools to generate metadata_*.go files
  • Update load meta data logic