Files
1dc12107b4 Add support for custom DNS resolver (#191)
* Initial plan

* Add support for custom DNS resolver

Co-authored-by: NeoCN <[email protected]>

* Simplify nil-handling per review feedback

Co-authored-by: NeoCN <[email protected]>

* Add test covering custom resolver usage in dialSMTP

Co-authored-by: NeoCN <[email protected]>

* Assert custom resolver error is propagated in dialSMTP test

Co-authored-by: NeoCN <[email protected]>

* Accept document suggestion

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix: use require.ErrorContains for error assertion in smtp_test.go (testifylint)

Co-authored-by: NeoCN <[email protected]>

* fix: reorder assertions to ensure called.Load() check runs before require

Co-authored-by: NeoCN <[email protected]>

* test: cover Resolver(nil) restoring the default resolver, add changelog entry

The nil-handling branch in Resolver() was uncovered: TestNewVerifierOK_DefaultResolver
only checks the default set by NewVerifier(), and TestVerifier_Resolver only passes a
non-nil resolver, so nothing exercised the reset path.

Confirmed the new test is a real guard rather than a tautology: with the
`if resolver == nil` branch removed, it fails with actual (*net.Resolver)(nil); with the
branch restored it passes.

Also adds the missing CHANGELOG entry for this feature, matching the convention in the
Unreleased section.

Coverage 89.4% -> 89.6%.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix: read net.DefaultResolver at lookup time, not at construction

NewVerifier captured net.DefaultResolver into the resolver field, which
silently broke callers who redirect DNS by replacing that global -- the only
way to do it before this option existed, and the exact workflow described in
 #186.

The order matters and the failing one is the common one. Verified against
main and against this branch with an external program calling CheckMX:

                        main    before fix   after fix
  construct-then-patch  honored  IGNORED      honored
  patch-then-construct  honored  honored      honored

"construct-then-patch" is the usual shape because README recommends a package
level `var verifier = emailverifier.NewVerifier()`, and package variables are
initialised before any init() that would install the replacement. The failure
was silent: no error, the lookup just went to the system resolver.

Keep the field nil unless Resolver() is called and resolve the default through
a dnsResolver() accessor, mirroring what the old net.LookupMX call did (it
reads net.DefaultResolver on every call). An explicit Resolver() still takes
precedence over the global; confirmed by the same program.

Also drops the now-redundant nil coercion in Resolver(), so nil means "use the
dynamic default" rather than pinning to whatever the global happened to be --
which is what its doc comment already claimed.

Coverage unchanged at 89.6%.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* docs: explain why PreferGo is required in the resolver example

The example sets PreferGo: true with no explanation, so it reads as
boilerplate that a reader could reasonably delete -- which would silently
break half the feature.

Measured on darwin/arm64 with a custom Dial that records whether it was
invoked:

  PreferGo=false   LookupMX honors Dial=true    host->IP honors Dial=false
  PreferGo=true    LookupMX honors Dial=true    host->IP honors Dial=true

This matches the stdlib: Resolver.lookupMX calls goLookupMX unconditionally
(net/lookup_unix.go), so MX records never take the cgo path, while lookupIP
consults hostLookupOrder and can return hostLookupCgo, which ignores Dial.
PreferGo makes mustUseGoResolver true, and that is the first branch in
conf.lookupOrder, so it wins.

Also documents that /etc/hosts is consulted before DNS even with PreferGo
set, verified the same way: resolving "localhost" never reaches the custom
Dial.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* test: pin that the proxy path bypasses the custom resolver

The README states that SMTP hostname resolution follows the proxy path rather
than the configured resolver, but nothing enforced it: the existing
TestDialSMTP_WithCustomResolver only covers proxyURI == "", and
TestNewSMTPClientFailed_WithInvalidProxy passes net.DefaultResolver and only
asserts the connection is refused.

Adds the counterpart asserting the resolver is NOT consulted when a proxy is
set, so the documented behaviour is executable. If the proxy path ever gains
resolver support this test fails and prompts a docs update.

Measured before writing it, via dialSMTP directly:

  no proxy          resolverConsulted=true
  proxy (IP host)   resolverConsulted=false
  proxy (hostname)  resolverConsulted=false

The test uses an IP for the proxy so that resolving the proxy's own name
cannot influence the assertion. Verified it is a real guard: making the proxy
branch call resolver.LookupHost makes it fail with "Should be false".

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* docs: clarify that MX lookups are not routed through the proxy

The preceding sentence, "when a proxy is configured, SMTP hostname resolution
follows the proxy path rather than this resolver", can be read as "the
resolver is irrelevant once a proxy is set". Only the MX-host-to-IP step goes
through the proxy; the MX record lookup itself always uses this resolver.

In smtp.go the lookup is at resolver.LookupMX before proxyURI is consulted at
all, which happens later in dialSMTP. TestDialSMTP_ProxyBypassesCustomResolver
pins the other half of this, that the proxy path does not consult the
resolver.

Worth stating explicitly because it means DNS queries still originate from the
local machine when a proxy is configured.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: NeoCN <[email protected]>
Co-authored-by: Jason Ma <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 12:03:26 +08:00

26 lines
586 B
Go

package emailverifier
import (
"context"
"net"
)
// Mx is detail about the Mx host
type Mx struct {
HasMXRecord bool // whether has 1 or more MX record
Records []*net.MX // represent DNS MX records
}
// CheckMX will return the DNS MX records for the given domain name sorted by preference.
func (v *Verifier) CheckMX(domain string) (*Mx, error) {
domain = domainToASCII(domain)
mx, err := v.dnsResolver().LookupMX(context.Background(), domain)
if err != nil && len(mx) == 0 {
return nil, err
}
return &Mx{
HasMXRecord: len(mx) > 0,
Records: mx,
}, nil
}