- ip_family 默认 ipv4(保持 PTR/SPF 最可靠的路径);运营商为静态 IPv6 配置 PTR 后可切换 ipv6 - source_ip 绑定出站源地址,避免内核使用轮换的 IPv6 临时隐私地址 (临时地址无 PTR,Gmail 等会拒收) - 无 MX 回退时按地址族偏好排序 A/AAAA
474 lines
14 KiB
Go
474 lines
14 KiB
Go
// Package outbound implements external (outbound) email delivery.
|
|
//
|
|
// Messages queued for external recipients are stored in the outbound_messages
|
|
// table and delivered by the Manager's background worker: MX lookup, SMTP
|
|
// transaction over port 25 with opportunistic STARTTLS, exponential backoff
|
|
// retries, permanent-failure bounces and DKIM signing. A smarthost relay can
|
|
// be configured for servers whose own IP is blocklisted (e.g. PBL).
|
|
package outbound
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/textproto"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DeliveryError wraps an SMTP delivery failure and records whether it is
|
|
// permanent (5xx / NXDOMAIN / invalid address) or temporary (4xx / network /
|
|
// timeout). Temporary failures are retried by the queue worker.
|
|
type DeliveryError struct {
|
|
Permanent bool
|
|
Code int
|
|
Msg string
|
|
}
|
|
|
|
func (e *DeliveryError) Error() string {
|
|
if e.Code > 0 {
|
|
return fmt.Sprintf("%d %s", e.Code, e.Msg)
|
|
}
|
|
return e.Msg
|
|
}
|
|
|
|
// newTempError creates a temporary delivery error.
|
|
func newTempError(format string, args ...interface{}) *DeliveryError {
|
|
return &DeliveryError{Permanent: false, Msg: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// newPermError creates a permanent delivery error.
|
|
func newPermError(format string, args ...interface{}) *DeliveryError {
|
|
return &DeliveryError{Permanent: true, Msg: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// RelayConfig describes a smarthost through which all external mail is sent.
|
|
type RelayConfig struct {
|
|
Host string
|
|
Port int // 465 = implicit TLS; other ports may use STARTTLS
|
|
Username string // AUTH PLAIN credentials (empty = no authentication)
|
|
Password string
|
|
StartTLS bool // use STARTTLS on non-465 ports
|
|
}
|
|
|
|
// Mailer performs direct MX delivery (or smarthost relay) of a single message.
|
|
type Mailer struct {
|
|
Hostname string // EHLO hostname presented to remote servers
|
|
Port int // destination port, 0 means the default SMTP port 25
|
|
Relay *RelayConfig
|
|
IPFamily string // "ipv4" (default), "ipv6" or "auto"
|
|
SourceIP string // optional source address to bind (e.g. a static IPv6)
|
|
ConnectTimeout time.Duration
|
|
}
|
|
|
|
// NewMailer creates a Mailer with the given EHLO hostname and connect timeout.
|
|
func NewMailer(hostname string, connectTimeout time.Duration) *Mailer {
|
|
if hostname == "" {
|
|
hostname = "localhost"
|
|
}
|
|
return &Mailer{Hostname: hostname, ConnectTimeout: connectTimeout}
|
|
}
|
|
|
|
// port returns the destination port, defaulting to 25.
|
|
func (m *Mailer) port() int {
|
|
if m.Port == 0 {
|
|
return 25
|
|
}
|
|
return m.Port
|
|
}
|
|
|
|
// Deliver sends one message to one recipient. When a relay is configured the
|
|
// message goes through the smarthost; otherwise the recipient domain's MX is
|
|
// used. It returns the final SMTP response text on success and a
|
|
// *DeliveryError on failure.
|
|
func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
|
|
if m.Relay != nil && m.Relay.Host != "" {
|
|
return m.deliverViaRelay(from, to, data)
|
|
}
|
|
|
|
at := strings.LastIndex(to, "@")
|
|
if at < 0 || at == len(to)-1 {
|
|
return "", newPermError("invalid recipient address: %s", to)
|
|
}
|
|
domain := strings.ToLower(strings.TrimSpace(to[at+1:]))
|
|
|
|
mxHosts, err := lookupMX(domain, m.IPFamily)
|
|
if err != nil {
|
|
var de *DeliveryError
|
|
if errors.As(err, &de) {
|
|
return "", de
|
|
}
|
|
return "", newTempError("MX lookup failed for %s: %v", domain, err)
|
|
}
|
|
|
|
var lastErr *DeliveryError
|
|
for _, host := range mxHosts {
|
|
resp, err := m.deliverToHost(host, from, to, data)
|
|
if err == nil {
|
|
return resp, nil
|
|
}
|
|
var de *DeliveryError
|
|
if errors.As(err, &de) {
|
|
lastErr = de
|
|
// A permanent failure from one MX applies to the whole message,
|
|
// do not try other MX hosts.
|
|
if de.Permanent {
|
|
return "", de
|
|
}
|
|
continue
|
|
}
|
|
lastErr = newTempError("delivery to %s failed: %v", host, err)
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = newTempError("no MX hosts available for %s", domain)
|
|
}
|
|
return "", lastErr
|
|
}
|
|
|
|
// deliverViaRelay sends the message through the configured smarthost.
|
|
func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
|
|
port := m.Relay.Port
|
|
if port == 0 {
|
|
port = 587
|
|
}
|
|
implicitTLS := port == 465
|
|
return m.smtpTransaction(m.Relay.Host, port, implicitTLS,
|
|
m.Relay.StartTLS && !implicitTLS,
|
|
m.Relay.Username, m.Relay.Password, from, to, data)
|
|
}
|
|
|
|
// smtpClient wraps a textproto connection to a remote SMTP server.
|
|
type smtpClient struct {
|
|
conn net.Conn
|
|
txt *textproto.Conn
|
|
host string
|
|
exts map[string]string // advertised EHLO extensions (upper-case key -> params)
|
|
}
|
|
|
|
func (c *smtpClient) Close() {
|
|
if c.txt != nil {
|
|
_ = c.txt.Close()
|
|
}
|
|
}
|
|
|
|
// cmd sends a command and expects the given reply codes, returning the
|
|
// response text. Codes other than expected are returned as a DeliveryError.
|
|
func (c *smtpClient) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
|
|
if err := c.txt.PrintfLine(format, args...); err != nil {
|
|
return 0, "", newTempError("write to %s failed: %v", c.host, err)
|
|
}
|
|
code, msg, err := c.txt.ReadResponse(expectCode)
|
|
if err != nil {
|
|
return code, msg, classifyResponse(err, msg)
|
|
}
|
|
return code, msg, nil
|
|
}
|
|
|
|
// classifyResponse converts a textproto error (wrong reply code) into a
|
|
// DeliveryError, keeping the actual SMTP code and text.
|
|
func classifyResponse(err error, fallback string) *DeliveryError {
|
|
var protoErr *textproto.Error
|
|
if errors.As(err, &protoErr) {
|
|
return &DeliveryError{
|
|
Permanent: protoErr.Code >= 500,
|
|
Code: protoErr.Code,
|
|
Msg: protoErr.Msg,
|
|
}
|
|
}
|
|
if fallback != "" {
|
|
return newTempError("%s", fallback)
|
|
}
|
|
return newTempError("%v", err)
|
|
}
|
|
|
|
// hello sends EHLO and records the advertised extensions. If EHLO fails it
|
|
// falls back to HELO for very old servers.
|
|
func (c *smtpClient) hello(hostname string) error {
|
|
if err := c.txt.PrintfLine("EHLO %s", hostname); err != nil {
|
|
return newTempError("write EHLO to %s failed: %v", c.host, err)
|
|
}
|
|
code, msg, err := c.txt.ReadResponse(250)
|
|
if err != nil {
|
|
// Fall back to HELO.
|
|
if err := c.txt.PrintfLine("HELO %s", hostname); err != nil {
|
|
return newTempError("write HELO to %s failed: %v", c.host, err)
|
|
}
|
|
code, msg, err = c.txt.ReadResponse(250)
|
|
if err != nil {
|
|
return classifyResponse(err, msg)
|
|
}
|
|
return nil
|
|
}
|
|
_ = code
|
|
c.exts = map[string]string{}
|
|
for _, line := range strings.Split(msg, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, " ", 2)
|
|
key := strings.ToUpper(parts[0])
|
|
val := ""
|
|
if len(parts) == 2 {
|
|
val = parts[1]
|
|
}
|
|
c.exts[key] = val
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authPlain performs AUTH PLAIN with the initial-response form, falling back
|
|
// to the two-step form when the server asks for credentials separately.
|
|
func (c *smtpClient) authPlain(username, password string) error {
|
|
b64 := base64.StdEncoding.EncodeToString([]byte("\x00" + username + "\x00" + password))
|
|
code, msg, err := c.cmd(235, "AUTH PLAIN %s", b64)
|
|
if err != nil {
|
|
if code == 334 {
|
|
_, _, err = c.cmd(235, "%s", b64)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_ = msg
|
|
return nil
|
|
}
|
|
|
|
// deliverToHost performs a full SMTP transaction with a single MX host.
|
|
func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) {
|
|
return m.smtpTransaction(host, m.port(), false, false, "", "", from, to, data)
|
|
}
|
|
|
|
// smtpTransaction performs one complete SMTP session: connect, greeting,
|
|
// optional implicit TLS / STARTTLS, optional AUTH PLAIN, MAIL/RCPT/DATA/QUIT.
|
|
func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bool, username, password, from, to string, data []byte) (string, error) {
|
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
|
|
defer cancel()
|
|
|
|
conn, err := m.dialSMTP(ctx, addr)
|
|
if err != nil {
|
|
return "", newTempError("connect to %s failed: %v", addr, err)
|
|
}
|
|
|
|
c := &smtpClient{conn: conn, txt: textproto.NewConn(conn), host: host}
|
|
defer c.Close()
|
|
|
|
// Read greeting (expect 220).
|
|
if _, msg, err := c.txt.ReadResponse(220); err != nil {
|
|
return "", classifyResponse(err, msg)
|
|
}
|
|
|
|
tlsServerName := host
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
tlsServerName = "" // no SNI for IP literals
|
|
}
|
|
|
|
if implicitTLS {
|
|
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.txt = textproto.NewConn(tlsConn)
|
|
if err := c.hello(m.Hostname); err != nil {
|
|
return "", err
|
|
}
|
|
} else {
|
|
if err := c.hello(m.Hostname); err != nil {
|
|
return "", err
|
|
}
|
|
// Opportunistic STARTTLS: only when the server advertises it, unless
|
|
// startTLS is explicitly requested (smarthost), in which case a
|
|
// non-advertising server is an error.
|
|
_, adv := c.exts["STARTTLS"]
|
|
if adv || startTLS {
|
|
if !adv && startTLS {
|
|
return "", newTempError("%s does not advertise STARTTLS", host)
|
|
}
|
|
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
|
|
return "", err
|
|
}
|
|
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.txt = textproto.NewConn(tlsConn)
|
|
if err := c.hello(m.Hostname); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
|
|
if username != "" {
|
|
if err := c.authPlain(username, password); err != nil {
|
|
return "", fmt.Errorf("AUTH PLAIN with %s failed: %w", host, err)
|
|
}
|
|
}
|
|
|
|
// MAIL FROM with BODY=8BITMIME when the message contains 8-bit bytes and
|
|
// the remote server supports it.
|
|
mailCmd := "MAIL FROM:<%s>"
|
|
if is8Bit(data) {
|
|
if _, ok := c.exts["8BITMIME"]; ok {
|
|
mailCmd = "MAIL FROM:<%s> BODY=8BITMIME"
|
|
} else {
|
|
return "", newPermError("%s does not advertise 8BITMIME and the message contains 8-bit data", host)
|
|
}
|
|
}
|
|
if _, _, err := c.cmd(250, mailCmd, from); err != nil {
|
|
return "", err
|
|
}
|
|
if _, _, err := c.cmd(250, "RCPT TO:<%s>", to); err != nil {
|
|
return "", err
|
|
}
|
|
if _, _, err := c.cmd(354, "DATA"); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Write the message body with dot-stuffing.
|
|
dw := c.txt.DotWriter()
|
|
if _, err := dw.Write(data); err != nil {
|
|
_ = dw.Close()
|
|
return "", newTempError("writing message data to %s failed: %v", host, err)
|
|
}
|
|
if err := dw.Close(); err != nil {
|
|
return "", newTempError("finalizing message data to %s failed: %v", host, err)
|
|
}
|
|
|
|
code, msg, err := c.txt.ReadResponse(250)
|
|
if err != nil {
|
|
return "", classifyResponse(err, msg)
|
|
}
|
|
|
|
// Best-effort QUIT.
|
|
_ = c.txt.PrintfLine("QUIT")
|
|
_, _, _ = c.txt.ReadResponse(221)
|
|
|
|
return fmt.Sprintf("%d %s", code, msg), nil
|
|
}
|
|
|
|
// tlsClientHandshake upgrades a plain connection to TLS.
|
|
func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host string) (net.Conn, error) {
|
|
tlsConn := tls.Client(conn, &tls.Config{
|
|
ServerName: serverName,
|
|
InsecureSkipVerify: true, // remote MX certificates often cannot be verified
|
|
})
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
return nil, newTempError("TLS handshake with %s failed: %v", host, err)
|
|
}
|
|
return tlsConn, nil
|
|
}
|
|
|
|
// dialSMTP connects to a remote SMTP server, honoring the configured IP
|
|
// family and optional source address binding.
|
|
//
|
|
// The default is IPv4-only: many receiving systems (e.g. Gmail) reject mail
|
|
// from IPv6 addresses without PTR records, and the IPv4 address of a mail
|
|
// host usually has a forward-confirmed PTR and a matching SPF entry. Switch
|
|
// IPFamily to "ipv6"/"auto" after the ISP has configured a PTR for the
|
|
// source address and SourceIP binds the connection to that static address.
|
|
func (m *Mailer) dialSMTP(ctx context.Context, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
network := "tcp4"
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
// Literal destination: pick the matching family.
|
|
if ip.To4() == nil {
|
|
network = "tcp6"
|
|
}
|
|
} else {
|
|
switch strings.ToLower(m.IPFamily) {
|
|
case "ipv6":
|
|
network = "tcp6"
|
|
case "auto":
|
|
network = "tcp"
|
|
default: // "ipv4" and anything unrecognized
|
|
network = "tcp4"
|
|
}
|
|
}
|
|
|
|
dialer := &net.Dialer{Timeout: m.ConnectTimeout}
|
|
if m.SourceIP != "" {
|
|
if ip := net.ParseIP(m.SourceIP); ip != nil {
|
|
dialer.LocalAddr = &net.TCPAddr{IP: ip}
|
|
}
|
|
}
|
|
return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
|
|
}
|
|
|
|
// is8Bit reports whether the data contains any byte >= 0x80.
|
|
func is8Bit(data []byte) bool {
|
|
for _, b := range data {
|
|
if b >= 0x80 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lookupMX resolves the MX hosts for a domain, sorted by preference.
|
|
// Per RFC 5321 section 5.1, when no MX record exists the domain itself is
|
|
// used as an implicit MX with preference 0. ipFamily controls the ordering
|
|
// of the A/AAAA fallback ("ipv6" puts IPv6 first, otherwise IPv4 first).
|
|
func lookupMX(domain, ipFamily string) ([]string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
mxs, err := net.DefaultResolver.LookupMX(ctx, domain)
|
|
if err != nil {
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
|
return nil, newPermError("domain does not exist: %s", domain)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if len(mxs) == 0 {
|
|
// Implicit MX: fall back to the domain's A/AAAA records.
|
|
ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var hosts []string
|
|
var v6 []string
|
|
for _, ip := range ips {
|
|
if ip.IP.To4() != nil {
|
|
hosts = append(hosts, ip.IP.String())
|
|
} else {
|
|
v6 = append(v6, ip.IP.String())
|
|
}
|
|
}
|
|
if strings.EqualFold(ipFamily, "ipv6") {
|
|
hosts = append(v6, hosts...)
|
|
} else {
|
|
hosts = append(hosts, v6...)
|
|
}
|
|
if len(hosts) == 0 {
|
|
return nil, fmt.Errorf("no MX or A records for %s", domain)
|
|
}
|
|
return hosts, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
if len(hosts) == 0 {
|
|
return nil, fmt.Errorf("no usable MX hosts for %s", domain)
|
|
}
|
|
return hosts, nil
|
|
}
|