feat: 出站地址族可配置(ip_family=ipv4/ipv6/auto)+ 源地址绑定(source_ip)
- ip_family 默认 ipv4(保持 PTR/SPF 最可靠的路径);运营商为静态 IPv6 配置 PTR 后可切换 ipv6 - source_ip 绑定出站源地址,避免内核使用轮换的 IPv6 临时隐私地址 (临时地址无 PTR,Gmail 等会拒收) - 无 MX 回退时按地址族偏好排序 A/AAAA
This commit is contained in:
@@ -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 等策略列表收录,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+42
-16
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user