verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
174 lines
5.7 KiB
Go
174 lines
5.7 KiB
Go
// 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
|
|
}
|