initial
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user