b3c22c261e
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.
142 lines
3.5 KiB
Go
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
|
|
}
|