feat: harden SMTP transport
Add explicit TLS modes, context-aware delivery, and typed transport errors. Preserve raw MIME messages and cover the new delivery paths with local SMTP tests.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user