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.
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user