Files
emailsender/pkg/email/errors.go
T
urko b3c22c261e 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.
2026-08-18 18:16:22 -06:00

142 lines
3.5 KiB
Go

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
}