mirror of
https://github.com/hanzoai/email-verifier.git
synced 2026-10-30 15:04:29 +00:00
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]>
446 lines
15 KiB
Go
446 lines
15 KiB
Go
package emailverifier
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"net/textproto"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestParse550RCPTError(t *testing.T) {
|
|
err := errors.New("550 This mailbox does not exist")
|
|
le := ParseSMTPError(err)
|
|
assert.Equal(t, ErrServerUnavailable, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParse550BlockedRCPTError(t *testing.T) {
|
|
err := errors.New("550 spamhaus")
|
|
le := ParseSMTPError(err)
|
|
assert.Equal(t, ErrBlocked, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseConnectMailExchangerError(t *testing.T) {
|
|
err := errors.New("Timeout connecting to mail-exchanger")
|
|
le := ParseSMTPError(err)
|
|
assert.Equal(t, ErrTimeout, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseNoMxRecordsFoundError(t *testing.T) {
|
|
errStr := "No MX records found"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
assert.Equal(t, errStr, le.Message)
|
|
assert.Equal(t, errStr, le.Details)
|
|
}
|
|
|
|
func TestParseFullInBoxError(t *testing.T) {
|
|
errStr := "452 full Inbox"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrFullInbox, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseDailSMTPServerError(t *testing.T) {
|
|
errStr := "Unexpected response dialing SMTP server"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
assert.Equal(t, errStr, le.Message)
|
|
assert.Equal(t, errStr, le.Details)
|
|
}
|
|
|
|
func TestParseError_Code550(t *testing.T) {
|
|
errStr := "550"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrServerUnavailable, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
// A status line that does not itself indicate a failure is still reported,
|
|
// because ParseSMTPError is only ever reached with a non-nil error. Returning
|
|
// nil used to hand callers a non-nil error interface wrapping a nil
|
|
// *LookupError, and calling Error on that panics.
|
|
func TestParseError_UnclassifiedIsReportedVerbatim(t *testing.T) {
|
|
// Anything whose status line parses to 400 or below takes this branch. Real
|
|
// servers do not send a success code as an error, but the guarantee is about
|
|
// the function's contract: it is only ever called with a non-nil error, so it
|
|
// must not discard one.
|
|
for _, reply := range []string{"200 OK", "300 Redirect", "399", "400"} {
|
|
test := reply
|
|
t.Run(test, func(tt *testing.T) {
|
|
cause := errors.New(test)
|
|
|
|
le := ParseSMTPError(cause)
|
|
|
|
require.NotNil(tt, le)
|
|
assert.Equal(tt, test, le.Message)
|
|
assert.Equal(tt, test, le.Details)
|
|
assert.NotPanics(tt, func() { _ = le.Error() })
|
|
// the cause has to survive on this branch too, not just the
|
|
// classified one
|
|
assert.ErrorIs(tt, error(le), cause)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseSMTPError_NilInput(t *testing.T) {
|
|
assert.Nil(t, ParseSMTPError(nil))
|
|
}
|
|
|
|
func TestParseSMTPError_UnwrapsToCause(t *testing.T) {
|
|
cause := &net.DNSError{Err: "no such host", Name: "example.invalid", IsNotFound: true}
|
|
|
|
le := ParseSMTPError(cause)
|
|
require.NotNil(t, le)
|
|
assert.Equal(t, ErrNoSuchHost, le.Message)
|
|
|
|
// the point of wrapping: callers can inspect the underlying error by type
|
|
// instead of matching on Details
|
|
var dnsErr *net.DNSError
|
|
require.ErrorAs(t, error(le), &dnsErr)
|
|
assert.True(t, dnsErr.IsNotFound)
|
|
assert.ErrorIs(t, error(le), cause)
|
|
}
|
|
|
|
func TestLookupError_UnwrapNilSafe(t *testing.T) {
|
|
var le *LookupError
|
|
assert.NoError(t, le.Unwrap())
|
|
}
|
|
|
|
func TestParseError_Code401(t *testing.T) {
|
|
errStr := "401"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, errStr, le.Message)
|
|
assert.Equal(t, errStr, le.Details)
|
|
}
|
|
|
|
func TestParseError_Code421(t *testing.T) {
|
|
errStr := "421"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrTryAgainLater, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code450(t *testing.T) {
|
|
errStr := "450"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrMailboxBusy, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code451(t *testing.T) {
|
|
errStr := "451"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrExceededMessagingLimits, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code452(t *testing.T) {
|
|
errStr := "452"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrTooManyRCPT, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code503(t *testing.T) {
|
|
errStr := "503"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrNeedMAILBeforeRCPT, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code551(t *testing.T) {
|
|
errStr := "551"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrRCPTHasMoved, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code552(t *testing.T) {
|
|
errStr := "552"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrFullInbox, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code553(t *testing.T) {
|
|
errStr := "553"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrNoRelay, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_Code554(t *testing.T) {
|
|
errStr := "554"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrNotAllowed, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_basicErr_timeout(t *testing.T) {
|
|
errStr := "559 timeout"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrTimeout, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
func TestParseError_basicErr_blocked(t *testing.T) {
|
|
errStr := "559 blocked"
|
|
err := errors.New(errStr)
|
|
le := ParseSMTPError(err)
|
|
|
|
assert.Equal(t, ErrBlocked, le.Message)
|
|
assert.Equal(t, err.Error(), le.Details)
|
|
}
|
|
|
|
// Replies captured 2026-09-10 by driving the exchange directly, so the table
|
|
// doubles as a record of what enhanced status codes look like in the wild.
|
|
// Verbatim, session identifiers included; only our egress IP is replaced.
|
|
//
|
|
// Each is a *textproto.Error because that is what net/smtp returns. A table of
|
|
// plain strings passed while the feature was broken for every real reply.
|
|
func TestParseSMTPError_EnhancedCode(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
enhanced string
|
|
}{
|
|
{
|
|
// Multi-line: textproto joins the lines with \n, each repeating the code.
|
|
name: "gmail, recipient unknown, RCPT",
|
|
err: &textproto.Error{Code: 550, Msg: "5.1.1 The email account that you tried to reach does not exist. Please try\n" +
|
|
"5.1.1 double-checking the recipient's email address for typos or\n" +
|
|
"5.1.1 unnecessary spaces. For more information, go to\n" +
|
|
"5.1.1 https://support.google.com/mail/?p=NoSuchUser 41be03b00d2f7-cc4c687ec03si136868a12.335 - gsmtp"},
|
|
enhanced: "5.1.1",
|
|
},
|
|
{
|
|
// X.5.x for an unknown mailbox, not X.1.x: the subject is not a verdict.
|
|
name: "microsoft, recipient unknown, RCPT",
|
|
err: &textproto.Error{Code: 550, Msg: "5.5.0 Requested action not taken: mailbox unavailable (S2017062302). [SJ1PEPF000037A2.namprd04.prod.outlook.com 2026-09-10T17:20:46.631Z 08DF0B1332F1DE15]"},
|
|
enhanced: "5.5.0",
|
|
},
|
|
{
|
|
// Rejected at MAIL FROM: no recipient had been named.
|
|
name: "yahoo, sender fails FCrDNS, MAIL FROM",
|
|
err: &textproto.Error{Code: 550, Msg: "5.7.25 Forward-confirmed reverse DNS failed tnmpmscs"},
|
|
enhanced: "5.7.25",
|
|
},
|
|
{
|
|
name: "zoho, recipient unknown, RCPT",
|
|
err: &textproto.Error{Code: 550, Msg: "5.1.1 User does not exist - <[email protected]>"},
|
|
enhanced: "5.1.1",
|
|
},
|
|
{
|
|
// No enhanced code despite a 550 about the recipient. Egress IP replaced.
|
|
name: "qq sends no enhanced code, RCPT",
|
|
err: &textproto.Error{Code: 550, Msg: "Mailbox not found. http://service.mail.qq.com/detail/122/169 [MAW77ic63OtZB8WdcrvY/gIHPvUvsXupE8bE5roaPk+ab2Lvf59Ok0394SdGexrgVA== IP: 198.51.100.1]"},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
name: "netease sends no enhanced code, RCPT",
|
|
err: &textproto.Error{Code: 550, Msg: "User not found: [email protected]"},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
// Earlier run. The only transient reply captured, and for a real mailbox.
|
|
name: "microsoft, transient, RCPT",
|
|
err: &textproto.Error{Code: 452, Msg: "4.5.3 Recipients belong to multiple regions ATTR38 [AMS1EPF0000008F.eurprd05.prod.outlook.com]"},
|
|
enhanced: "4.5.3",
|
|
},
|
|
{
|
|
// Carries a bare IPv4 address the pattern must not mistake for a code.
|
|
name: "sender blocklisted, reply contains an IP address",
|
|
err: &textproto.Error{Code: 550, Msg: "5.7.1 Service unavailable, Client host [198.51.100.1] blocked using Spamhaus."},
|
|
enhanced: "5.7.1",
|
|
},
|
|
{
|
|
// X.7.x -- reserved for policy -- for an unknown recipient. With the
|
|
// case below, one code meaning two opposite things.
|
|
name: "yandex, recipient unknown, answered with 5.7.1",
|
|
err: &textproto.Error{Code: 550, Msg: "5.7.1 No such user! 1789063127-kwTVGUHdeiE0-huj9x1Wr"},
|
|
enhanced: "5.7.1",
|
|
},
|
|
{
|
|
// The same code, meaning our IP is blocklisted. No recipient was judged.
|
|
name: "apple, sender blocklisted, answered with 5.7.1",
|
|
err: &textproto.Error{Code: 550, Msg: "5.7.1 Mail from IP 198.51.100.1 was rejected due to listing in Spamhaus SBL. For details please see http://www.spamhaus.org/query/bl?ip=198.51.100.1"},
|
|
enhanced: "5.7.1",
|
|
},
|
|
{
|
|
// The default HelloName rejected at EHLO. 501 is not a code
|
|
// parseSMTPError switches on; see the Message test below.
|
|
name: "fastmail rejects EHLO localhost",
|
|
err: &textproto.Error{Code: 501, Msg: "5.7.1 <localhost>: Helo command rejected: HELO string 'localhost' not accepted, Use a real hostname/ip"},
|
|
enhanced: "5.7.1",
|
|
},
|
|
{
|
|
// Same rejection, but Hello returned nil and it surfaced at RCPT.
|
|
name: "tuta rejects EHLO localhost, surfacing at RCPT",
|
|
err: &textproto.Error{Code: 504, Msg: "5.5.2 <localhost>: Helo command rejected: need fully-qualified hostname"},
|
|
enhanced: "5.5.2",
|
|
},
|
|
{
|
|
// Refused at the greeting: multi-line, no enhanced code.
|
|
name: "united internet refuses the connection",
|
|
err: &textproto.Error{Code: 554, Msg: "gmx.net (mxgmx009) Nemesis ESMTP Service not available\n" +
|
|
"No SMTP service\n" +
|
|
"IP address is block listed.\n" +
|
|
"For explanation visit https://postmaster.gmx.net/en/case?c=r0303&i=ip&v=198.51.100.1&r=1N6a0i-1wlEnE2roU-012Max"},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
// The default FromEmail: example.org publishes v=spf1 -all.
|
|
name: "aliyun rejects the default sender on spf",
|
|
err: &textproto.Error{Code: 554, Msg: "Reject by behaviour spam at Rcpt State(Connection IP address:198.51.100.1)ANTISPAM_BAT[01201311R846a, maildocker-behaviorspam033068209079]: spf check failedCONTINUE"},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
// A rate limit wearing a 550, with no enhanced code to say so.
|
|
name: "sina rate-limits with a 550",
|
|
err: &textproto.Error{Code: 550, Msg: "Too many recipients."},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
name: "dns failure carries no reply",
|
|
err: &net.DNSError{Err: "no such host", Name: "icloud.com", IsNotFound: true},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
// Code and Msg are separate fields: a reply code we cannot parse says
|
|
// nothing about Msg. ParseSMTPError is exported and takes any error.
|
|
name: "unparseable reply code, enhanced code still read",
|
|
err: &textproto.Error{Code: 1234, Msg: "5.1.1 no such user"},
|
|
enhanced: "5.1.1",
|
|
},
|
|
{
|
|
name: "3xx reply, no enhanced code of a class we recognise",
|
|
err: &textproto.Error{Code: 354, Msg: "3.0.0 start mail input"},
|
|
enhanced: "",
|
|
},
|
|
{
|
|
name: "connection failure, rendered as a string",
|
|
err: errors.New("dial tcp 1.2.3.4:25: connect: connection refused"),
|
|
enhanced: "",
|
|
},
|
|
{
|
|
name: "reply rendered as a string",
|
|
err: errors.New("550 5.1.1 does not exist"),
|
|
enhanced: "5.1.1",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(tt *testing.T) {
|
|
le := ParseSMTPError(c.err)
|
|
require.NotNil(tt, le)
|
|
assert.Equal(tt, c.enhanced, le.EnhancedCode())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLookupError_EnhancedCodeNilSafe(t *testing.T) {
|
|
var le *LookupError
|
|
assert.Empty(t, le.EnhancedCode())
|
|
}
|
|
|
|
// A 4xx says the server could not answer now. Its text may still mention the
|
|
// recipient -- Microsoft's transient reply for a real mailbox does -- so the
|
|
// keyword check that reads a reply as a statement about the address has to be
|
|
// confined to permanent replies, or a rate limit is filed as a refusal.
|
|
func TestParseSMTPError_TransientRepliesReachTheSwitch(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
message string
|
|
}{
|
|
{"452 mentioning the recipient", &textproto.Error{Code: 452, Msg: "4.5.3 user unknown, try later"}, ErrTooManyRCPT},
|
|
{"421 mentioning the recipient", &textproto.Error{Code: 421, Msg: "4.7.0 mailbox does not exist right now"}, ErrTryAgainLater},
|
|
{"450 mentioning the recipient", &textproto.Error{Code: 450, Msg: "4.2.0 no mailbox here yet"}, ErrMailboxBusy},
|
|
{"the same phrase at 5xx still reads as the address", &textproto.Error{Code: 550, Msg: "5.1.1 user unknown"}, ErrServerUnavailable},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(tt *testing.T) {
|
|
assert.Equal(tt, c.message, ParseSMTPError(c.err).Message)
|
|
})
|
|
}
|
|
}
|
|
|
|
// One blocklist keyword list. The 550 branch and parseBasicErr had drifted
|
|
// apart, so whether a phrase counted depended on the reply code.
|
|
func TestParseSMTPError_BlockedKeywordsAreOneList(t *testing.T) {
|
|
for _, kw := range blockedKeywords {
|
|
t.Run(kw, func(tt *testing.T) {
|
|
// 550 is in the switch; 501 is not and falls to parseBasicErr.
|
|
for _, code := range []int{550, 501} {
|
|
le := ParseSMTPError(&textproto.Error{Code: code, Msg: "5.7.1 host is " + kw})
|
|
assert.Equal(tt, ErrBlocked, le.Message, "reply code %d", code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Details is meant to be the reply. textproto renders with %q, which is a debug
|
|
// form: it adds quotes the server never sent and turns the newlines of a
|
|
// multi-line reply into a literal \n.
|
|
func TestParseSMTPError_DetailsIsTheReplyNotItsRendering(t *testing.T) {
|
|
t.Run("multi-line keeps its lines", func(tt *testing.T) {
|
|
le := ParseSMTPError(&textproto.Error{Code: 550, Msg: "5.1.1 line one\n5.1.1 line two"})
|
|
assert.Equal(tt, "550 5.1.1 line one\n5.1.1 line two", le.Details)
|
|
})
|
|
|
|
t.Run("the server's own quotes survive unescaped", func(tt *testing.T) {
|
|
le := ParseSMTPError(&textproto.Error{Code: 550, Msg: `5.7.1 see "docs"`})
|
|
assert.Equal(tt, `550 5.7.1 see "docs"`, le.Details)
|
|
})
|
|
|
|
// Message is built from the same text for a reply code parseSMTPError does
|
|
// not switch on, so it changes with Details rather than separately.
|
|
t.Run("an unswitched reply code carries the reply in Message too", func(tt *testing.T) {
|
|
le := ParseSMTPError(&textproto.Error{Code: 501, Msg: "5.7.1 <localhost>: Helo command rejected"})
|
|
assert.Equal(tt, "501 5.7.1 <localhost>: Helo command rejected", le.Message)
|
|
assert.Equal(tt, le.Message, le.Details)
|
|
})
|
|
|
|
t.Run("a code outside SMTP's classes is not read as a recipient verdict", func(tt *testing.T) {
|
|
le := ParseSMTPError(&textproto.Error{Code: 600, Msg: "6.1.1 user unknown"})
|
|
assert.Equal(tt, "600 6.1.1 user unknown", le.Message)
|
|
})
|
|
|
|
t.Run("errors carrying no reply are unchanged", func(tt *testing.T) {
|
|
err := &net.DNSError{Err: "no such host", Name: "x.invalid", IsNotFound: true}
|
|
assert.Equal(tt, err.Error(), ParseSMTPError(err).Details)
|
|
})
|
|
}
|