// SPDX-License-Identifier: MPL-2.0 package mail import ( "context" "crypto/tls" "crypto/x509" "errors" "fmt" "net" stdmail "net/mail" "net/smtp" "net/textproto" "strconv" "strings" "time" ) type TLSMode string const ( ImplicitTLS TLSMode = "implicit" RequiredSTARTTLS TLSMode = "starttls" ) // SMTPConfig is trusted server configuration, never an HTTP payload. Keep its // credentials in private operator-managed storage and do not log this value. type SMTPConfig struct { Host string Port int TLSMode TLSMode Username, Password, From string Timeout time.Duration // RootCAs supplies an explicit trust pool (for example private PKI). // Nil uses system roots. Hostname verification cannot be disabled. RootCAs *x509.CertPool } func (SMTPConfig) String() string { return "mail.SMTPConfig{credentials:redacted}" } func (config SMTPConfig) GoString() string { return config.String() } type SMTP struct { address, host, username, password string from *stdmail.Address tlsMode TLSMode timeout time.Duration rootCAs *x509.CertPool } func (*SMTP) String() string { return "mail.SMTP{credentials:redacted}" } func (transport *SMTP) GoString() string { return transport.String() } // Error contains only a fixed stage and numeric SMTP code, never the server's // response (which can echo a mailbox, authentication token or message content). type Error struct { Stage string Code int Retryable bool } func (err *Error) Error() string { return fmt.Sprintf("mail: SMTP %s failed (code %d)", err.Stage, err.Code) } func NewSMTP(config SMTPConfig) (*SMTP, error) { if config.Timeout == 0 { config.Timeout = 15 * time.Second } if !headerText(config.Host, 253) || strings.ContainsAny(config.Host, " \t\r\n/@[]\\") || config.Port < 1 || config.Port > 65535 || (config.TLSMode != ImplicitTLS && config.TLSMode != RequiredSTARTTLS) || config.Timeout < time.Second || config.Timeout > 30*time.Second || !headerText(config.Username, 254) || config.Password == "" || len(config.Password) > 4096 || strings.ContainsAny(config.Password, "\x00\r\n") || !headerText(config.From, 320) { return nil, ErrInvalid } from, err := stdmail.ParseAddress(config.From) if err != nil || !validMailbox(from.Address) || from.Name != "" && !headerText(from.Name, 128) { return nil, ErrInvalid } var roots *x509.CertPool if config.RootCAs != nil { roots = config.RootCAs.Clone() } return &SMTP{address: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), host: config.Host, username: config.Username, password: config.Password, from: from, tlsMode: config.TLSMode, timeout: config.Timeout, rootCAs: roots}, nil } func failure(ctx context.Context, stage string, err error) error { if ctx.Err() != nil { return ctx.Err() } result := &Error{Stage: stage, Retryable: true} var protocol *textproto.Error if errors.As(err, &protocol) { result.Code = protocol.Code result.Retryable = protocol.Code >= 400 && protocol.Code < 500 } var unknownAuthority x509.UnknownAuthorityError var hostname x509.HostnameError var invalidCertificate x509.CertificateInvalidError if errors.As(err, &unknownAuthority) || errors.As(err, &hostname) || errors.As(err, &invalidCertificate) { result.Retryable = false } return result } // Send never falls back to plaintext, even on localhost. Cancellation closes the // connection and every protocol step shares one bounded deadline. SMTP can accept // DATA just before a connection failure; retrying that uncertainty may duplicate // mail. A failure of QUIT after DATA acceptance does not trigger another send. func (transport *SMTP) Send(ctx context.Context, message Message) error { if transport == nil { return ErrInvalid } payload, err := encode(message, transport.from) if err != nil { return err } ctx, cancel := context.WithTimeout(ctx, transport.timeout) defer cancel() connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", transport.address) if err != nil { return failure(ctx, "connect", err) } defer connection.Close() stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) defer stop() deadline, _ := ctx.Deadline() if err = connection.SetDeadline(deadline); err != nil { return failure(ctx, "connect", err) } tlsConfig := &tls.Config{ServerName: transport.host, MinVersion: tls.VersionTLS12, RootCAs: transport.rootCAs} var wire net.Conn = connection if transport.tlsMode == ImplicitTLS { secure := tls.Client(connection, tlsConfig) if err = secure.HandshakeContext(ctx); err != nil { return failure(ctx, "tls", err) } wire = secure } client, err := smtp.NewClient(wire, transport.host) if err != nil { return failure(ctx, "greeting", err) } defer client.Close() if transport.tlsMode == RequiredSTARTTLS { if ok, _ := client.Extension("STARTTLS"); !ok { return &Error{Stage: "starttls-required"} } if err = client.StartTLS(tlsConfig); err != nil { return failure(ctx, "tls", err) } } if err = client.Auth(smtp.PlainAuth("", transport.username, transport.password, transport.host)); err != nil { return failure(ctx, "authentication", err) } if err = client.Mail(transport.from.Address); err != nil { return failure(ctx, "sender", err) } if err = client.Rcpt(message.To); err != nil { return failure(ctx, "recipient", err) } data, err := client.Data() if err != nil { return failure(ctx, "data", err) } if _, err = data.Write(payload); err != nil { return failure(ctx, "data", err) } if err = data.Close(); err != nil { return failure(ctx, "acceptance", err) } _ = client.Quit() return nil }