Merge pull request 'fix: 外发优先 IPv4 + smarthost 中继 + 地址族配置(补 PR #1 合并后遗漏的提交)' (#3) from dsh/mailgo:outbound-ipv4-relay into main

Reviewed-on: kevin/mailgo#3
This commit is contained in:
2026-08-16 00:13:00 -04:00
5 changed files with 357 additions and 26 deletions
+31
View File
@@ -117,6 +117,13 @@ max_recipients = 50 # 单封邮件最大外部收件人数
max_per_min = 30 # 每用户每分钟最大外发数
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
connect_timeout = 30 # 连接远程 MX 超时(秒)
relay_host = "" # 智能主机(smarthost),留空则直投 MX
relay_port = 587 # 465 = 隐式 TLS,其他端口按需 STARTTLS
relay_user = "" # 中继认证用户名(AUTH PLAIN
relay_password = "" # 中继认证密码
relay_starttls = true # 非 465 端口是否使用 STARTTLS
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
```
---
@@ -258,6 +265,30 @@ 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 等策略列表收录,
MicrosoftOutlook/Hotmail)等收件方会直接拒收。此时建议把外发邮件交给
第三方 SMTP 中继(Mailgun / SendGrid / Amazon SES / 阿里云邮件推送等),
`[outbound]` 中配置即可,所有外部投递自动改走中继:
```toml
[outbound]
relay_host = "smtp.example-relay.com"
relay_port = 587 # 465 为隐式 TLS
relay_user = "your-api-user"
relay_password = "your-api-key"
relay_starttls = true
```
中继使用 AUTH PLAIN 认证;本地收件人仍走本地投递,不受影响。
---
## 端口速查
+30 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/BurntSushi/toml"
)
@@ -88,6 +89,19 @@ 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
// 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.
@@ -177,7 +191,10 @@ func defaultConfig() *Config {
MaxRecipients: 50, // 单封最多 50 个外部收件人
MaxPerMin: 30, // 每用户每分钟 30 封
MaxPerDay: 500,
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
ConnectTimeout: 30, // 连接远程 MX 超时 30 秒
RelayPort: 587, // smarthost 默认提交端口
RelayStartTLS: true,
IPFamily: "ipv4",
},
}
}
@@ -260,6 +277,12 @@ 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
}
if cfg.Outbound.IPFamily == "" {
cfg.Outbound.IPFamily = defaults.Outbound.IPFamily
}
return cfg
}
@@ -310,6 +333,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 !strings.Contains(string(data), "relay_starttls") {
cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS
}
// Merge defaults for any missing fields
merged := mergeDefaults(cfg, defaults)
+165 -25
View File
@@ -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,22 @@ 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
IPFamily string // "ipv4" (default), "ipv6" or "auto"
SourceIP string // optional source address to bind (e.g. a static IPv6)
ConnectTimeout time.Duration
}
@@ -68,17 +82,22 @@ 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)
}
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) {
@@ -111,6 +130,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 +222,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 := m.dialSMTP(ctx, addr)
if err != nil {
return "", newTempError("connect to %s failed: %v", addr, err)
}
@@ -212,26 +265,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 +353,58 @@ 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, 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 {
@@ -288,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()
@@ -308,9 +438,19 @@ func lookupMX(domain string) ([]string, error) {
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())
}
}
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)
+115
View File
@@ -2,6 +2,7 @@ package outbound
import (
"bufio"
"encoding/base64"
"net"
"strings"
"testing"
@@ -225,3 +226,117 @@ func TestMailerPermanentFailure(t *testing.T) {
}
<-done
}
func TestMailerSmarthostRelay(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
type result struct {
gotData []byte
authLine string
}
ch := make(chan result, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
ch <- result{}
return
}
defer conn.Close()
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
_, _ = w.WriteString("220 relay.test ESMTP\r\n")
_ = w.Flush()
var authLine string
var got []byte
for {
line, err := r.ReadString('\n')
if err != nil {
break
}
trimmed := strings.TrimRight(line, "\r\n")
up := strings.ToUpper(trimmed)
switch {
case strings.HasPrefix(up, "EHLO"):
_, _ = w.WriteString("250-relay.test\r\n250-8BITMIME\r\n250 AUTH PLAIN\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "AUTH PLAIN"):
authLine = trimmed
_, _ = w.WriteString("235 2.0.0 ok\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "MAIL FROM"):
if authLine == "" {
_, _ = w.WriteString("530 5.7.0 auth required\r\n")
_ = w.Flush()
break
}
_, _ = w.WriteString("250 ok\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "RCPT TO"):
_, _ = w.WriteString("250 ok\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "DATA"):
_, _ = w.WriteString("354 go\r\n")
_ = w.Flush()
for {
dl, err := r.ReadString('\n')
if err != nil {
break
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
if strings.HasPrefix(dl, "..") {
dl = dl[1:]
}
got = append(got, []byte(dl)...)
}
_, _ = w.WriteString("250 queued\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "QUIT"):
_, _ = w.WriteString("221 bye\r\n")
_ = w.Flush()
ch <- result{gotData: got, authLine: authLine}
return
}
}
ch <- result{}
}()
m := NewMailer("mail.lmve.net", 10*time.Second)
m.Relay = &RelayConfig{
Host: "127.0.0.1",
Port: ln.Addr().(*net.TCPAddr).Port,
Username: "relay-user",
Password: "relay-pass",
StartTLS: false,
}
// The recipient domain does not even exist — with a relay configured,
// no MX lookup happens and the relay still receives the message.
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
resp, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
if err != nil {
t.Fatalf("Deliver via relay: %v", err)
}
if !strings.HasPrefix(resp, "250") {
t.Fatalf("unexpected relay response: %q", resp)
}
res := <-ch
if res.authLine == "" {
t.Fatal("relay did not receive AUTH PLAIN")
}
wantAuth := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00relay-user\x00relay-pass"))
if res.authLine != wantAuth {
t.Fatalf("auth line mismatch: got %q want %q", res.authLine, wantAuth)
}
if string(res.gotData) != string(input) {
t.Fatalf("relay data mismatch.\ngot: %q\nwant: %q", res.gotData, input)
}
}
+16
View File
@@ -56,6 +56,22 @@ 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{
Host: cfg.RelayHost,
Port: cfg.RelayPort,
Username: cfg.RelayUser,
Password: cfg.RelayPassword,
StartTLS: cfg.RelayStartTLS,
}
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
}
return m
}