package email import ( "context" "crypto/x509" "errors" "fmt" "io" "net" "net/textproto" "strings" "time" ) // ErrorKind is the transport-level disposition callers use to decide whether // a delivery may be retried. Context cancellation remains separately visible // through errors.Is. type ErrorKind string const ( ErrorKindTransient ErrorKind = "transient" ErrorKindPermanent ErrorKind = "permanent" ErrorKindCanceled ErrorKind = "canceled" ) const maximumErrorDetailLength = 256 // Error describes a failed SMTP transport stage without exposing credentials // or message contents. Err remains wrapped for errors.Is/errors.As inspection. type Error struct { Kind ErrorKind Operation string SMTPCode int Err error } func (err *Error) Error() string { if err == nil { return "smtp transport failed" } operation := strings.TrimSpace(err.Operation) if operation == "" { operation = "smtp" } detail := safeErrorDetail(err.Err) if err.SMTPCode > 0 { return fmt.Sprintf("%s failed (SMTP %d): %s", operation, err.SMTPCode, detail) } return fmt.Sprintf("%s failed: %s", operation, detail) } func (err *Error) Unwrap() error { if err == nil { return nil } return err.Err } // Temporary reports whether retrying the same logical delivery can reasonably // succeed without changing its content or configuration. func (err *Error) Temporary() bool { return err != nil && err.Kind == ErrorKindTransient } func classifyError(ctx context.Context, operation string, cause error, fallback ErrorKind) error { if cause == nil { return nil } if contextErr := ctx.Err(); contextErr != nil { return &Error{ Kind: ErrorKindCanceled, Operation: operation, Err: errors.Join(contextErr, cause), } } if deadline, hasDeadline := ctx.Deadline(); hasDeadline && !time.Now().Before(deadline) { return &Error{ Kind: ErrorKindCanceled, Operation: operation, Err: errors.Join(context.DeadlineExceeded, cause), } } if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) { return &Error{Kind: ErrorKindCanceled, Operation: operation, Err: cause} } kind := fallback code := 0 var smtpError *textproto.Error switch { case errors.As(cause, &smtpError): code = smtpError.Code switch { case code >= 400 && code < 500: kind = ErrorKindTransient case code >= 500 && code < 600: kind = ErrorKindPermanent } case isCertificateError(cause): kind = ErrorKindPermanent default: var networkError net.Error if errors.As(cause, &networkError) || errors.Is(cause, io.ErrUnexpectedEOF) || errors.Is(cause, io.EOF) { kind = ErrorKindTransient } } return &Error{Kind: kind, Operation: operation, SMTPCode: code, Err: cause} } func permanentError(operation string, cause error) error { return &Error{Kind: ErrorKindPermanent, Operation: operation, Err: cause} } func isCertificateError(err error) bool { var unknownAuthority x509.UnknownAuthorityError var hostname x509.HostnameError var invalid x509.CertificateInvalidError return errors.As(err, &unknownAuthority) || errors.As(err, &hostname) || errors.As(err, &invalid) } func safeErrorDetail(err error) string { if err == nil { return "unknown error" } detail := strings.Map(func(character rune) rune { switch character { case '\r', '\n', '\t': return ' ' default: return character } }, strings.TrimSpace(err.Error())) if detail == "" { detail = "unknown error" } if len(detail) > maximumErrorDetailLength { detail = detail[:maximumErrorDetailLength] } return detail }