This commit is contained in:
2026-05-21 20:27:58 +00:00
parent 0d2615a9fd
commit 5a127bf2a2
38 changed files with 8644 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
// Package delivery implements outbound SMTP delivery: MX lookup, connection,
// TLS upgrade, message submission. Used by the queue worker.
package delivery
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"sort"
"strings"
"time"
)
const (
deliveryTimeout = 60 * time.Second
connectTimeout = 30 * time.Second
)
// Result holds the outcome of a single delivery attempt.
type Result struct {
MXHost string
SMTPCode int
Message string
Perm bool // true = permanent failure (5xx), don't retry
}
// Deliver attempts to deliver raw to the address to using a fresh SMTP
// connection to the recipient domain's MX. Signs with the given EHLO hostname.
// Returns a Result describing success or failure.
func Deliver(ctx context.Context, ehloHostname, from, to string, raw []byte) *Result {
at := strings.LastIndex(to, "@")
if at < 0 {
return &Result{Perm: true, Message: "invalid recipient address: " + to}
}
toDomain := strings.ToLower(to[at+1:])
mxHosts, err := lookupMX(ctx, toDomain)
if err != nil {
return &Result{Message: fmt.Sprintf("MX lookup %s: %v", toDomain, err)}
}
if len(mxHosts) == 0 {
return &Result{Perm: true, Message: "no MX records for " + toDomain}
}
var lastResult *Result
for _, host := range mxHosts {
r := deliver(ctx, ehloHostname, host, from, to, raw)
lastResult = r
if r.SMTPCode == 0 || r.SMTPCode/100 == 4 {
// Temp error or connection failure — try next MX.
continue
}
// 2xx = success, 5xx = permanent failure — stop trying.
return r
}
return lastResult
}
// deliver connects to one MX host and submits the message.
func deliver(ctx context.Context, ehloHostname, mxHost, from, to string, raw []byte) *Result {
addr := net.JoinHostPort(mxHost, "25")
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr)
if err != nil {
return &Result{MXHost: mxHost, Message: fmt.Sprintf("connect %s: %v", addr, err)}
}
// Wrap in a deadline for the full SMTP exchange.
deadline := time.Now().Add(deliveryTimeout)
_ = conn.SetDeadline(deadline)
c, err := smtp.NewClient(conn, mxHost)
if err != nil {
conn.Close()
return &Result{MXHost: mxHost, Message: fmt.Sprintf("smtp client %s: %v", mxHost, err)}
}
defer c.Close()
// EHLO.
if err := c.Hello(ehloHostname); err != nil {
return &Result{MXHost: mxHost, Message: fmt.Sprintf("EHLO: %v", err)}
}
// Try STARTTLS (best effort — not all remote servers require it).
if ok, _ := c.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{
ServerName: mxHost,
MinVersion: tls.VersionTLS12,
}
if err := c.StartTLS(tlsCfg); err != nil {
log.Printf("[delivery] STARTTLS %s failed (continuing plain): %v", mxHost, err)
}
}
// MAIL FROM.
if err := c.Mail(from); err != nil {
return smtpResult(mxHost, err)
}
// RCPT TO.
if err := c.Rcpt(to); err != nil {
return smtpResult(mxHost, err)
}
// DATA.
wc, err := c.Data()
if err != nil {
return smtpResult(mxHost, err)
}
if _, err := wc.Write(raw); err != nil {
wc.Close()
return &Result{MXHost: mxHost, Message: fmt.Sprintf("write data: %v", err)}
}
if err := wc.Close(); err != nil {
return smtpResult(mxHost, err)
}
_ = c.Quit()
log.Printf("[delivery] delivered %s → %s via %s", from, to, mxHost)
return &Result{MXHost: mxHost, SMTPCode: 250, Message: "2.0.0 OK"}
}
// lookupMX resolves MX records and returns hosts sorted by priority.
func lookupMX(ctx context.Context, domain string) ([]string, error) {
r := net.DefaultResolver
mxs, err := r.LookupMX(ctx, domain)
if err != nil {
// Treat NXDOMAIN as no-MX (not a transient error).
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
// Fall back: try A record (some small domains don't publish MX).
addrs, aerr := r.LookupHost(ctx, domain)
if aerr != nil || len(addrs) == 0 {
return nil, nil
}
return []string{domain}, nil
}
return nil, err
}
// Sort by priority ascending.
sort.Slice(mxs, func(i, j int) bool {
return mxs[i].Pref < mxs[j].Pref
})
hosts := make([]string, 0, len(mxs))
for _, mx := range mxs {
h := strings.TrimSuffix(mx.Host, ".")
if h != "" {
hosts = append(hosts, h)
}
}
return hosts, nil
}
// smtpResult maps an smtp error to a Result, marking 5xx as permanent.
func smtpResult(mxHost string, err error) *Result {
if err == nil {
return &Result{MXHost: mxHost, SMTPCode: 250, Message: "2.0.0 OK"}
}
msg := err.Error()
code := parseCode(msg)
return &Result{
MXHost: mxHost,
SMTPCode: code,
Message: msg,
Perm: code/100 == 5,
}
}
// parseCode extracts the leading 3-digit SMTP code from an error string.
func parseCode(s string) int {
if len(s) < 3 {
return 0
}
var code int
_, _ = fmt.Sscanf(s[:3], "%d", &code)
return code
}
// IsLocal reports whether the given domain is served locally. Used by the
// queue worker to skip outbound delivery for internal mail.
// The caller supplies its own domain list to avoid a DB call here.
func IsLocal(domain string, localDomains []string) bool {
domain = strings.ToLower(domain)
for _, d := range localDomains {
if strings.EqualFold(d, domain) {
return true
}
}
return false
}
// RecipientDomain returns the domain part of an email address.
func RecipientDomain(addr string) string {
at := strings.LastIndex(addr, "@")
if at < 0 {
return ""
}
return strings.ToLower(addr[at+1:])
}
// _ suppresses unused import if bytes is only used in tests later.
var _ = bytes.NewReader