// 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