From 1b81e5ccf815692b95799bfa7b23824873ca7dfe Mon Sep 17 00:00:00 2001 From: dsh Date: Sat, 15 Aug 2026 23:45:44 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=87=BA=E7=AB=99=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E6=97=8F=E5=8F=AF=E9=85=8D=E7=BD=AE=EF=BC=88ip=5Ffamily=3Dipv4?= =?UTF-8?q?/ipv6/auto=EF=BC=89+=20=E6=BA=90=E5=9C=B0=E5=9D=80=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=EF=BC=88source=5Fip=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ip_family 默认 ipv4(保持 PTR/SPF 最可靠的路径);运营商为静态 IPv6 配置 PTR 后可切换 ipv6 - source_ip 绑定出站源地址,避免内核使用轮换的 IPv6 临时隐私地址 (临时地址无 PTR,Gmail 等会拒收) - 无 MX 回退时按地址族偏好排序 A/AAAA --- README.md | 8 +++++ config/config.go | 8 +++++ internal/outbound/mailer.go | 58 ++++++++++++++++++++++++++---------- internal/outbound/manager.go | 5 ++++ 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5c57d8f..8df36eb 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ relay_port = 587 # 465 = 隐式 TLS,其他端口按需 relay_user = "" # 中继认证用户名(AUTH PLAIN) relay_password = "" # 中继认证密码 relay_starttls = true # 非 465 端口是否使用 STARTTLS +ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto +source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择 ``` --- @@ -263,6 +265,12 @@ max_per_day = 500 # 设为 0 可完全禁用外部投递 每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱; 管理员可在后台「外发队列」查看投递状态、手动重试或取消。 +> **IPv4/IPv6**:默认仅使用 IPv4 出站(`ip_family = "ipv4"`),因为很多收件方 +> (如 Gmail)会拒收没有 PTR 的 IPv6 地址,而 IPv4 通常具备正反向一致的 PTR。 +> 如需走 IPv6:请运营商为静态地址配置 PTR(指向 `mail.example.com`), +> 然后设置 `ip_family = "ipv6"` 并把 `source_ip` 绑定到该静态地址 +> (避免内核使用轮换的临时隐私地址)。 + ### 7. 通过智能主机(smarthost)中继外发 服务器 IP 属于家庭宽带/动态 IP 段时,常被 Spamhaus PBL 等策略列表收录, diff --git a/config/config.go b/config/config.go index eda0e1d..45f258b 100644 --- a/config/config.go +++ b/config/config.go @@ -98,6 +98,10 @@ type OutboundConfig struct { RelayUser string `toml:"relay_user"` // 中继认证用户名(AUTH PLAIN) RelayPassword string `toml:"relay_password"` // 中继认证密码 RelayStartTLS bool `toml:"relay_starttls"` // 非 465 端口是否使用 STARTTLS + + // IP family and source address binding for outbound connections. + IPFamily string `toml:"ip_family"` // ipv4(默认,PTR/SPF 最可靠)| ipv6 | auto + SourceIP string `toml:"source_ip"` // 出站源地址绑定(如静态 IPv6),留空由内核选择 } // Config is the top-level configuration structure. @@ -190,6 +194,7 @@ func defaultConfig() *Config { ConnectTimeout: 30, // 连接远程 MX 超时 30 秒 RelayPort: 587, // smarthost 默认提交端口 RelayStartTLS: true, + IPFamily: "ipv4", }, } } @@ -275,6 +280,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config { if cfg.Outbound.RelayPort == 0 { cfg.Outbound.RelayPort = defaults.Outbound.RelayPort } + if cfg.Outbound.IPFamily == "" { + cfg.Outbound.IPFamily = defaults.Outbound.IPFamily + } return cfg } diff --git a/internal/outbound/mailer.go b/internal/outbound/mailer.go index 62700fe..35186f7 100644 --- a/internal/outbound/mailer.go +++ b/internal/outbound/mailer.go @@ -61,6 +61,8 @@ 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 } @@ -95,7 +97,7 @@ func (m *Mailer) Deliver(from, to string, data []byte) (string, error) { } domain := strings.ToLower(strings.TrimSpace(to[at+1:])) - mxHosts, err := lookupMX(domain) + mxHosts, err := lookupMX(domain, m.IPFamily) if err != nil { var de *DeliveryError if errors.As(err, &de) { @@ -250,7 +252,7 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout) defer cancel() - conn, err := dialSMTP(ctx, addr, m.ConnectTimeout) + conn, err := m.dialSMTP(ctx, addr) if err != nil { return "", newTempError("connect to %s failed: %v", addr, err) } @@ -363,23 +365,43 @@ func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host str 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) { +// 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 && ip.To4() == nil { - network = "tcp6" + 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: timeout} + 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)) } @@ -395,8 +417,9 @@ func is8Bit(data []byte) bool { // 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. -func lookupMX(domain string) ([]string, error) { +// 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() @@ -410,8 +433,7 @@ func lookupMX(domain string) ([]string, error) { } if len(mxs) == 0 { - // Implicit MX: fall back to the domain's A/AAAA records, - // preferring IPv4 (see dialSMTP for the rationale). + // Implicit MX: fall back to the domain's A/AAAA records. ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain) if err != nil { return nil, err @@ -425,7 +447,11 @@ func lookupMX(domain string) ([]string, error) { v6 = append(v6, ip.IP.String()) } } - hosts = append(hosts, v6...) + 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) } diff --git a/internal/outbound/manager.go b/internal/outbound/manager.go index d4987c5..cf008ce 100644 --- a/internal/outbound/manager.go +++ b/internal/outbound/manager.go @@ -56,6 +56,11 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores lim: make(map[uint]*userWindow), batch: 50, } + m.mailer.IPFamily = cfg.IPFamily + m.mailer.SourceIP = cfg.SourceIP + if cfg.SourceIP != "" { + log.Printf("outbound: binding source address %s (ip_family=%s)", cfg.SourceIP, cfg.IPFamily) + } if cfg.RelayHost != "" { m.mailer.Relay = &RelayConfig{