Files

150 lines
4.7 KiB
Go

package email
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"sort"
"strconv"
"strings"
"time"
)
// SMTPProbeResult describes what a bound SMTP server actually offers, so an
// operator can tell "wrong host" from "reachable but will not accept our
// credentials" from "needs TLS and we are not using it".
type SMTPProbeResult struct {
Host string `json:"host"`
Port int `json:"port"`
Provider string `json:"provider"`
Greeting string `json:"greeting"`
Capabilities []string `json:"capabilities"`
STARTTLSOffered bool `json:"starttlsOffered"`
TLSEstablished bool `json:"tlsEstablished"`
AuthAccepted bool `json:"authAccepted"`
// AuthAttempted is false when the binding carries no credentials, which is
// the normal shape of a local test server.
AuthAttempted bool `json:"authAttempted"`
}
// ProbeSMTP opens a connection to the configured server, negotiates the same
// transport the sender would use, and reports what it found. It sends no mail.
//
// The probe deliberately reuses the sender's transport rules rather than a
// friendlier variant: a probe that succeeds by being more permissive than the
// real send would let a binding verify as healthy while every message fails.
func ProbeSMTP(ctx context.Context, cfg ClientConfig) (*SMTPProbeResult, error) {
if strings.TrimSpace(cfg.SMTPHost) == "" {
return nil, fmt.Errorf("smtp host is not configured")
}
port := cfg.SMTPPort
if port <= 0 {
port = 587
}
addr := net.JoinHostPort(cfg.SMTPHost, strconv.Itoa(port))
timeout := 15 * time.Second
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining > 0 && remaining < timeout {
timeout = remaining
}
}
dialer := &net.Dialer{Timeout: timeout}
result := &SMTPProbeResult{
Host: cfg.SMTPHost,
Port: port,
Provider: string(cfg.Provider),
}
var (
conn net.Conn
client *smtp.Client
tlsConfigured bool
err error
)
if port == 465 || cfg.SMTPUseTLS {
tlsConfigured = true
tlsConfig := &tls.Config{ServerName: cfg.SMTPHost}
if cfg.SMTPAllowInsecure {
tlsConfig.InsecureSkipVerify = true
}
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return result, fmt.Errorf("failed to connect via tls: %w", err)
}
result.TLSEstablished = true
} else {
conn, err = dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return result, fmt.Errorf("failed to dial smtp: %w", err)
}
}
client, err = smtp.NewClient(conn, cfg.SMTPHost)
if err != nil {
_ = conn.Close()
return result, fmt.Errorf("failed to create smtp client: %w", err)
}
defer func() {
_ = client.Quit()
}()
// net/smtp exposes no extension lister, so the capabilities that actually
// affect delivery are probed by name. A raw list would be noise to an operator.
if err = client.Hello("localhost"); err != nil {
_ = conn.Close()
return result, fmt.Errorf("smtp greeting failed: %w", err)
}
if ext, _ := client.Extension("STARTTLS"); ext {
result.STARTTLSOffered = true
if !tlsConfigured && !cfg.SMTPAllowInsecure {
tlsConfig := &tls.Config{ServerName: cfg.SMTPHost}
if err = client.StartTLS(tlsConfig); err != nil {
return result, fmt.Errorf("starttls failed (enable allow-insecure for a self-signed test server): %w", err)
}
result.TLSEstablished = true
}
}
capabilities := make([]string, 0, 6)
for _, ext := range []string{"8BITMIME", "SMTPUTF8", "PIPELINING", "SIZE", "ENHANCEDSTATUSCODES", "AUTH PLAIN"} {
if ok, _ := client.Extension(ext); ok {
capabilities = append(capabilities, strings.ToUpper(ext))
}
}
sort.Strings(capabilities)
result.Capabilities = capabilities
if strings.TrimSpace(cfg.SMTPUser) != "" && strings.TrimSpace(cfg.SMTPPassword) != "" {
result.AuthAttempted = true
if ok, _ := client.Extension("AUTH"); ok {
auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPassword, cfg.SMTPHost)
// PlainAuth itself refuses to send credentials in the clear, which would
// report as an auth failure rather than as the transport problem it is.
if !result.TLSEstablished && !isLocalhostHost(cfg.SMTPHost) {
return result, fmt.Errorf(
"refusing to authenticate over an unencrypted connection: enable TLS, or allow-insecure for a local test server")
}
if err = client.Auth(auth); err != nil {
return result, fmt.Errorf("smtp auth failed: %w", err)
}
result.AuthAccepted = true
}
}
return result, nil
}
func isLocalhostHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if i := strings.LastIndex(h, ":"); i >= 0 && !strings.Contains(h, "]") {
h = h[:i]
}
return h == "localhost" || h == "127.0.0.1" || h == "::1"
}