From 8fb7aef052b2bb2abfc643c4090ebe51e530e1a6 Mon Sep 17 00:00:00 2001 From: dsh Date: Sat, 15 Aug 2026 23:39:57 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E5=A4=96=E5=8F=91=E4=BC=98=E5=85=88?= =?UTF-8?q?=E8=B5=B0=20IPv4=EF=BC=88Gmail=20=E6=8B=92=E6=94=B6=E6=97=A0=20?= =?UTF-8?q?PTR=20=E7=9A=84=20IPv6=EF=BC=89=EF=BC=9B=E6=96=B0=E5=A2=9E=20sm?= =?UTF-8?q?arthost=20=E4=B8=AD=E7=BB=A7=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 出站 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 中继单元测试 --- README.md | 23 +++++ config/config.go | 23 ++++- internal/outbound/mailer.go | 160 ++++++++++++++++++++++++++----- internal/outbound/mailer_test.go | 115 ++++++++++++++++++++++ internal/outbound/manager.go | 11 +++ 5 files changed, 308 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2a691cc..5c57d8f 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,11 @@ 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 ``` --- @@ -258,6 +263,24 @@ max_per_day = 500 # 设为 0 可完全禁用外部投递 每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱; 管理员可在后台「外发队列」查看投递状态、手动重试或取消。 +### 7. 通过智能主机(smarthost)中继外发 + +服务器 IP 属于家庭宽带/动态 IP 段时,常被 Spamhaus PBL 等策略列表收录, +Microsoft(Outlook/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 认证;本地收件人仍走本地投递,不受影响。 + --- ## 端口速查 diff --git a/config/config.go b/config/config.go index 09bbcad..bed7a65 100644 --- a/config/config.go +++ b/config/config.go @@ -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) diff --git a/internal/outbound/mailer.go b/internal/outbound/mailer.go index 4f0e562..62700fe 100644 --- a/internal/outbound/mailer.go +++ b/internal/outbound/mailer.go @@ -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) } diff --git a/internal/outbound/mailer_test.go b/internal/outbound/mailer_test.go index 44a35d5..7d8ef89 100644 --- a/internal/outbound/mailer_test.go +++ b/internal/outbound/mailer_test.go @@ -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) + } +} diff --git a/internal/outbound/manager.go b/internal/outbound/manager.go index 3eaff6a..d4987c5 100644 --- a/internal/outbound/manager.go +++ b/internal/outbound/manager.go @@ -56,6 +56,17 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores lim: make(map[uint]*userWindow), batch: 50, } + + 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 } -- 2.47.3 From 76c98c94f34091734eb3bf39c8ca8a9ae85582d6 Mon Sep 17 00:00:00 2001 From: dsh Date: Sat, 15 Aug 2026 23:41:01 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20relay=5Fstarttls=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=E5=A7=8B=E7=BB=88=E4=B8=BA=20true=EF=BC=88?= =?UTF-8?q?=E6=9C=AA=E6=98=BE=E5=BC=8F=E9=85=8D=E7=BD=AE=E6=97=B6=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=AD=E7=BB=A7=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E6=98=8E=E6=96=87=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index bed7a65..eda0e1d 100644 --- a/config/config.go +++ b/config/config.go @@ -327,7 +327,7 @@ func LoadConfig() (*Config, error) { // 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") { + if !strings.Contains(string(data), "relay_starttls") { cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS } -- 2.47.3 From 7ce8751f46eed15c225258d0bd49ea5035c4be44 Mon Sep 17 00:00:00 2001 From: dsh Date: Sat, 15 Aug 2026 23:45:44 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E5=87=BA=E7=AB=99=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=97=8F=E5=8F=AF=E9=85=8D=E7=BD=AE=EF=BC=88ip=5Ffami?= =?UTF-8?q?ly=3Dipv4/ipv6/auto=EF=BC=89+=20=E6=BA=90=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E7=BB=91=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{ -- 2.47.3