fix: 外发优先走 IPv4(Gmail 拒收无 PTR 的 IPv6);新增 smarthost 中继支持
- 出站 SMTP 连接强制 IPv4(tcp4),无 MX 回退时 IPv4 优先: 住宅/动态 IP 的 IPv6 临时地址通常无 PTR,Gmail 会以 5.7.25 拒收, 而 IPv4 一般具备正反向一致的 PTR(实测 Gmail 250 OK) - [outbound] 新增 relay_host/relay_port/relay_user/relay_password/ relay_starttls:配置后所有外部投递经智能主机中继(AUTH PLAIN、 465 隐式 TLS / 其他端口 STARTTLS),解决服务器 IP 被 Spamhaus PBL 收录时 Outlook/Hotmail 拒收的问题 - 新增 smarthost 中继单元测试
This commit is contained in:
+137
-23
@@ -3,12 +3,14 @@
|
||||
// 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.
|
||||
// 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"
|
||||
@@ -45,10 +47,20 @@ func newPermError(format string, args ...interface{}) *DeliveryError {
|
||||
return &DeliveryError{Permanent: true, Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// Mailer performs direct MX delivery of a single message.
|
||||
// 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
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -68,10 +80,15 @@ func (m *Mailer) port() int {
|
||||
return m.Port
|
||||
}
|
||||
|
||||
// Deliver sends one message to one recipient via the recipient domain's MX.
|
||||
// It returns the final SMTP response text on success and a *DeliveryError on
|
||||
// failure.
|
||||
// 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)
|
||||
@@ -111,6 +128,18 @@ func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
|
||||
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
|
||||
@@ -191,15 +220,37 @@ func (c *smtpClient) hello(hostname string) error {
|
||||
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) {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(m.port()))
|
||||
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()
|
||||
|
||||
dialer := &net.Dialer{Timeout: m.ConnectTimeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
conn, err := dialSMTP(ctx, addr, m.ConnectTimeout)
|
||||
if err != nil {
|
||||
return "", newTempError("connect to %s failed: %v", addr, err)
|
||||
}
|
||||
@@ -212,26 +263,50 @@ func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, erro
|
||||
return "", classifyResponse(err, msg)
|
||||
}
|
||||
|
||||
if err := c.hello(m.Hostname); err != nil {
|
||||
return "", err
|
||||
tlsServerName := host
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
tlsServerName = "" // no SNI for IP literals
|
||||
}
|
||||
|
||||
// Opportunistic STARTTLS (RFC 3207): only when the server advertises it.
|
||||
if _, ok := c.exts["STARTTLS"]; ok {
|
||||
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
|
||||
if implicitTLS {
|
||||
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: true, // remote MX certificates often cannot be verified
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return "", newTempError("TLS handshake with %s failed: %v", host, 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
|
||||
@@ -276,6 +351,38 @@ func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, erro
|
||||
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 preferring IPv4.
|
||||
// Many receiving systems (e.g. Gmail) reject mail from IPv6 addresses
|
||||
// without PTR records; the IPv4 address of a mail host usually has a
|
||||
// forward-confirmed PTR and a matching SPF entry, so IPv4 is preferred.
|
||||
// A literal IPv6 destination is still reachable via tcp6.
|
||||
func dialSMTP(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
network := "tcp4"
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
|
||||
network = "tcp6"
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
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 {
|
||||
@@ -303,15 +410,22 @@ func lookupMX(domain string) ([]string, error) {
|
||||
}
|
||||
|
||||
if len(mxs) == 0 {
|
||||
// Implicit MX: fall back to the domain's A/AAAA records.
|
||||
// Implicit MX: fall back to the domain's A/AAAA records,
|
||||
// preferring IPv4 (see dialSMTP for the rationale).
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts := make([]string, 0, len(ips))
|
||||
var hosts []string
|
||||
var v6 []string
|
||||
for _, ip := range ips {
|
||||
hosts = append(hosts, ip.String())
|
||||
if ip.IP.To4() != nil {
|
||||
hosts = append(hosts, ip.IP.String())
|
||||
} else {
|
||||
v6 = append(v6, ip.IP.String())
|
||||
}
|
||||
}
|
||||
hosts = append(hosts, v6...)
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("no MX or A records for %s", domain)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user