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

265 lines
8.1 KiB
Go

package emailverifier
import (
"errors"
"fmt"
"net/textproto"
"regexp"
"strconv"
"strings"
)
const (
// Standard Errors
ErrTimeout = "The connection to the mail server has timed out"
ErrNoSuchHost = "Mail server does not exist"
ErrServerUnavailable = "Mail server is unavailable"
ErrBlocked = "Blocked by mail server"
// RCPT Errors
ErrTryAgainLater = "Try again later"
ErrFullInbox = "Recipient out of disk space"
ErrTooManyRCPT = "Too many recipients"
ErrNoRelay = "Not an open relay"
ErrMailboxBusy = "Mailbox busy"
ErrExceededMessagingLimits = "Messaging limits have been exceeded"
ErrNotAllowed = "Not Allowed"
ErrNeedMAILBeforeRCPT = "Need MAIL before RCPT"
ErrRCPTHasMoved = "Recipient has moved"
)
// LookupError is an MX dns records lookup error
type LookupError struct {
Message string `json:"message" xml:"message"`
Details string `json:"details" xml:"details"`
// cause is the error this was derived from. It is deliberately unexported
// and not serialised; it exists so that callers can use errors.Is and
// errors.As to reach the underlying *net.DNSError, *net.OpError or
// *textproto.Error instead of having to match on Details.
cause error
// enhanced is the RFC 3463 enhanced status code the server sent, if any.
enhanced string
}
// newLookupError creates a new LookupError reference and returns it
func newLookupError(message, details string) *LookupError {
return &LookupError{Message: message, Details: details}
}
// withCause records the error this LookupError was derived from, so that
// errors.Is and errors.As can see through to it.
func (e *LookupError) withCause(cause error) *LookupError {
if e == nil {
return nil
}
e.cause = cause
return e
}
// Unwrap returns the error this LookupError was derived from, if any.
func (e *LookupError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// EnhancedCode returns the RFC 3463 enhanced status code the server sent, for
// example "5.7.1", or an empty string if it sent none. Several large providers
// send none, so callers must handle the empty case rather than read it as a
// classification.
//
// The class digit is dependable -- 2 success, 4 transient, 5 permanent -- but
// the subject is evidence, not a verdict: providers disagree with the registry
// and with each other over which subject an unknown recipient gets.
func (e *LookupError) EnhancedCode() string {
if e == nil {
return ""
}
return e.enhanced
}
func (e *LookupError) Error() string {
return fmt.Sprintf("%s : %s", e.Message, e.Details)
}
// enhancedCodeInReplyPattern matches the code in a reply that reaches us
// flattened into one string: right after the reply code, so that the IP
// addresses and diagnostic identifiers these replies carry are not mistaken for
// one.
var enhancedCodeInReplyPattern = regexp.MustCompile(`^[245][0-9]{2}[ -]\s*([245]\.[0-9]{1,3}\.[0-9]{1,3})\b`)
// enhancedCodeInMsgPattern matches it where textproto keeps it: Code has been
// split off, so the enhanced code starts Msg.
var enhancedCodeInMsgPattern = regexp.MustCompile(`^\s*([245]\.[0-9]{1,3}\.[0-9]{1,3})\b`)
// blockedKeywords mark a reply as a refusal of us rather than of the address.
// One list: the 550 branch and parseBasicErr had drifted apart.
var blockedKeywords = []string{
"spamhaus",
"proofpoint",
"cloudmark",
"banned",
"blacklisted",
"blocked",
"block list",
"denied",
}
// replyText returns the server's reply as it arrived. textproto.Error renders
// with %q, which wraps the text in quotes and escapes a multi-line reply's
// newlines, so Error() is a debug form rather than the reply. Errors carrying no
// reply of their own fall back to it.
func replyText(err error) string {
var tp *textproto.Error
if errors.As(err, &tp) {
return fmt.Sprintf("%03d %s", tp.Code, tp.Msg)
}
return err.Error()
}
// enhancedCodeOf extracts the enhanced status code from an SMTP reply. It starts
// textproto.Error's Msg; Error() renders that with %q, which puts the code behind
// a quote, so the rendered form serves only replies that reach us already
// flattened -- ParseSMTPError is exported and takes those too.
func enhancedCodeOf(err error) string {
var tp *textproto.Error
if errors.As(err, &tp) {
if m := enhancedCodeInMsgPattern.FindStringSubmatch(tp.Msg); len(m) > 1 {
return m[1]
}
return ""
}
if m := enhancedCodeInReplyPattern.FindStringSubmatch(err.Error()); len(m) > 1 {
return m[1]
}
return ""
}
// ParseSMTPError receives an MX Servers response message
// and generates the corresponding MX error.
//
// A non-nil err always yields a non-nil result: an error we cannot classify is
// reported verbatim rather than discarded. Returning nil here would hand the
// caller a non-nil error interface wrapping a nil *LookupError, whose Error
// method then panics.
func ParseSMTPError(err error) *LookupError {
if err == nil {
return nil
}
le := parseSMTPError(err)
if le == nil {
errStr := replyText(err)
le = newLookupError(errStr, errStr)
}
le.enhanced = enhancedCodeOf(err)
return le.withCause(err)
}
func parseSMTPError(err error) *LookupError {
errStr := replyText(err)
// Verify the length of the error before reading nil indexes
if len(errStr) < 3 {
return parseBasicErr(err)
}
// Strips out the status code string and converts to an integer for parsing
status, convErr := strconv.Atoi(string([]rune(errStr)[0:3]))
if convErr != nil {
return parseBasicErr(err)
}
if status > 400 {
// Only a permanent reply can be read as a statement about the address: a
// 4xx says the server could not answer now, whatever its text mentions.
// Bounded above too, since ParseSMTPError is exported and a code outside
// SMTP's classes says nothing about a recipient either.
if status >= 500 && status < 600 && insContains(errStr,
"undeliverable",
"does not exist",
"may not exist",
"user unknown",
"user not found",
"invalid address",
"recipient invalid",
"recipient rejected",
"address rejected",
"no mailbox") {
return newLookupError(ErrServerUnavailable, errStr)
}
switch status {
case 421:
return newLookupError(ErrTryAgainLater, errStr)
case 450:
return newLookupError(ErrMailboxBusy, errStr)
case 451:
return newLookupError(ErrExceededMessagingLimits, errStr)
case 452:
if insContains(errStr,
"full",
"space",
"over quota",
"insufficient",
) {
return newLookupError(ErrFullInbox, errStr)
}
return newLookupError(ErrTooManyRCPT, errStr)
case 503:
return newLookupError(ErrNeedMAILBeforeRCPT, errStr)
case 550: // 550 is Mailbox Unavailable - usually undeliverable, ref: https://blog.mailtrap.io/550-5-1-1-rejected-fix/
if insContains(errStr, blockedKeywords...) {
return newLookupError(ErrBlocked, errStr)
}
return newLookupError(ErrServerUnavailable, errStr)
case 551:
return newLookupError(ErrRCPTHasMoved, errStr)
case 552:
return newLookupError(ErrFullInbox, errStr)
case 553:
return newLookupError(ErrNoRelay, errStr)
case 554:
return newLookupError(ErrNotAllowed, errStr)
default:
return parseBasicErr(err)
}
}
return nil
}
// parseBasicErr parses a basic MX record response and returns
// a more understandable LookupError
func parseBasicErr(err error) *LookupError {
errStr := replyText(err)
// Return a more understandable error
switch {
case insContains(errStr, blockedKeywords...):
return newLookupError(ErrBlocked, errStr)
case insContains(errStr, "timeout"):
return newLookupError(ErrTimeout, errStr)
case insContains(errStr, "no such host"):
return newLookupError(ErrNoSuchHost, errStr)
case insContains(errStr, "unavailable"):
return newLookupError(ErrServerUnavailable, errStr)
default:
return newLookupError(errStr, errStr)
}
}
// insContains returns true if any of the substrings
// are found in the passed string. This method of checking
// contains is case insensitive
func insContains(str string, subStrs ...string) bool {
for _, subStr := range subStrs {
if strings.Contains(strings.ToLower(str),
strings.ToLower(subStr)) {
return true
}
}
return false
}