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:
dsh
2026-08-16 00:09:53 -04:00
committed by root
parent 79220b816d
commit 8fb7aef052
5 changed files with 308 additions and 24 deletions
+22 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/BurntSushi/toml"
)
@@ -88,6 +89,15 @@ type OutboundConfig struct {
MaxPerMin int `toml:"max_per_min"` // 每用户每分钟最大外发数
MaxPerDay int `toml:"max_per_day"` // 每用户每日最大外发数,0 表示禁用外部投递
ConnectTimeout int `toml:"connect_timeout"` // 连接远程 MX 超时(秒)
// Smarthost relay: when relay_host is non-empty, all external mail is
// delivered through this relay instead of direct MX delivery. Useful when
// the server IP is listed in PBL/blocklists (residential/dynamic IPs).
RelayHost string `toml:"relay_host"` // 中继服务器地址,留空则直投 MX
RelayPort int `toml:"relay_port"` // 465 = 隐式 TLS,其他端口先尝试 STARTTLS
RelayUser string `toml:"relay_user"` // 中继认证用户名(AUTH PLAIN
RelayPassword string `toml:"relay_password"` // 中继认证密码
RelayStartTLS bool `toml:"relay_starttls"` // 非 465 端口是否使用 STARTTLS
}
// Config is the top-level configuration structure.
@@ -177,7 +187,9 @@ func defaultConfig() *Config {
MaxRecipients: 50, // 单封最多 50 个外部收件人
MaxPerMin: 30, // 每用户每分钟 30 封
MaxPerDay: 500,
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
RelayPort: 587, // smarthost 默认提交端口
RelayStartTLS: true,
},
}
}
@@ -260,6 +272,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
if cfg.Outbound.ConnectTimeout == 0 {
cfg.Outbound.ConnectTimeout = defaults.Outbound.ConnectTimeout
}
if cfg.Outbound.RelayPort == 0 {
cfg.Outbound.RelayPort = defaults.Outbound.RelayPort
}
return cfg
}
@@ -310,6 +325,12 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
// relay_starttls defaults to true for safety; the raw file is checked
// because TOML decoding cannot distinguish an absent bool from false.
if cfg.Outbound.RelayHost != "" && !strings.Contains(string(data), "relay_starttls") {
cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS
}
// Merge defaults for any missing fields
merged := mergeDefaults(cfg, defaults)