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:
@@ -117,6 +117,11 @@ max_recipients = 50 # 单封邮件最大外部收件人数
|
|||||||
max_per_min = 30 # 每用户每分钟最大外发数
|
max_per_min = 30 # 每用户每分钟最大外发数
|
||||||
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
|
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
|
||||||
connect_timeout = 30 # 连接远程 MX 超时(秒)
|
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 认证;本地收件人仍走本地投递,不受影响。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 端口速查
|
## 端口速查
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
@@ -88,6 +89,15 @@ type OutboundConfig struct {
|
|||||||
MaxPerMin int `toml:"max_per_min"` // 每用户每分钟最大外发数
|
MaxPerMin int `toml:"max_per_min"` // 每用户每分钟最大外发数
|
||||||
MaxPerDay int `toml:"max_per_day"` // 每用户每日最大外发数,0 表示禁用外部投递
|
MaxPerDay int `toml:"max_per_day"` // 每用户每日最大外发数,0 表示禁用外部投递
|
||||||
ConnectTimeout int `toml:"connect_timeout"` // 连接远程 MX 超时(秒)
|
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.
|
// Config is the top-level configuration structure.
|
||||||
@@ -178,6 +188,8 @@ func defaultConfig() *Config {
|
|||||||
MaxPerMin: 30, // 每用户每分钟 30 封
|
MaxPerMin: 30, // 每用户每分钟 30 封
|
||||||
MaxPerDay: 500,
|
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 {
|
if cfg.Outbound.ConnectTimeout == 0 {
|
||||||
cfg.Outbound.ConnectTimeout = defaults.Outbound.ConnectTimeout
|
cfg.Outbound.ConnectTimeout = defaults.Outbound.ConnectTimeout
|
||||||
}
|
}
|
||||||
|
if cfg.Outbound.RelayPort == 0 {
|
||||||
|
cfg.Outbound.RelayPort = defaults.Outbound.RelayPort
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +325,12 @@ func LoadConfig() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
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
|
// Merge defaults for any missing fields
|
||||||
merged := mergeDefaults(cfg, defaults)
|
merged := mergeDefaults(cfg, defaults)
|
||||||
|
|
||||||
|
|||||||
+137
-23
@@ -3,12 +3,14 @@
|
|||||||
// Messages queued for external recipients are stored in the outbound_messages
|
// Messages queued for external recipients are stored in the outbound_messages
|
||||||
// table and delivered by the Manager's background worker: MX lookup, SMTP
|
// table and delivered by the Manager's background worker: MX lookup, SMTP
|
||||||
// transaction over port 25 with opportunistic STARTTLS, exponential backoff
|
// 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
|
package outbound
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -45,10 +47,20 @@ func newPermError(format string, args ...interface{}) *DeliveryError {
|
|||||||
return &DeliveryError{Permanent: true, Msg: fmt.Sprintf(format, args...)}
|
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 {
|
type Mailer struct {
|
||||||
Hostname string // EHLO hostname presented to remote servers
|
Hostname string // EHLO hostname presented to remote servers
|
||||||
Port int // destination port, 0 means the default SMTP port 25
|
Port int // destination port, 0 means the default SMTP port 25
|
||||||
|
Relay *RelayConfig
|
||||||
ConnectTimeout time.Duration
|
ConnectTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +80,15 @@ func (m *Mailer) port() int {
|
|||||||
return m.Port
|
return m.Port
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliver sends one message to one recipient via the recipient domain's MX.
|
// Deliver sends one message to one recipient. When a relay is configured the
|
||||||
// It returns the final SMTP response text on success and a *DeliveryError on
|
// message goes through the smarthost; otherwise the recipient domain's MX is
|
||||||
// failure.
|
// 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) {
|
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, "@")
|
at := strings.LastIndex(to, "@")
|
||||||
if at < 0 || at == len(to)-1 {
|
if at < 0 || at == len(to)-1 {
|
||||||
return "", newPermError("invalid recipient address: %s", to)
|
return "", newPermError("invalid recipient address: %s", to)
|
||||||
@@ -111,6 +128,18 @@ func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
|
|||||||
return "", lastErr
|
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.
|
// smtpClient wraps a textproto connection to a remote SMTP server.
|
||||||
type smtpClient struct {
|
type smtpClient struct {
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -191,15 +220,37 @@ func (c *smtpClient) hello(hostname string) error {
|
|||||||
return nil
|
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.
|
// deliverToHost performs a full SMTP transaction with a single MX host.
|
||||||
func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
dialer := &net.Dialer{Timeout: m.ConnectTimeout}
|
conn, err := dialSMTP(ctx, addr, m.ConnectTimeout)
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", newTempError("connect to %s failed: %v", addr, err)
|
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)
|
return "", classifyResponse(err, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.hello(m.Hostname); err != nil {
|
tlsServerName := host
|
||||||
return "", err
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
tlsServerName = "" // no SNI for IP literals
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opportunistic STARTTLS (RFC 3207): only when the server advertises it.
|
if implicitTLS {
|
||||||
if _, ok := c.exts["STARTTLS"]; ok {
|
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
|
||||||
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
|
if err != nil {
|
||||||
return "", err
|
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)
|
c.txt = textproto.NewConn(tlsConn)
|
||||||
if err := c.hello(m.Hostname); err != nil {
|
if err := c.hello(m.Hostname); err != nil {
|
||||||
return "", err
|
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
|
// 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
|
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.
|
// is8Bit reports whether the data contains any byte >= 0x80.
|
||||||
func is8Bit(data []byte) bool {
|
func is8Bit(data []byte) bool {
|
||||||
for _, b := range data {
|
for _, b := range data {
|
||||||
@@ -303,15 +410,22 @@ func lookupMX(domain string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(mxs) == 0 {
|
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)
|
ips, err := net.DefaultResolver.LookupIPAddr(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hosts := make([]string, 0, len(ips))
|
var hosts []string
|
||||||
|
var v6 []string
|
||||||
for _, ip := range ips {
|
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 {
|
if len(hosts) == 0 {
|
||||||
return nil, fmt.Errorf("no MX or A records for %s", domain)
|
return nil, fmt.Errorf("no MX or A records for %s", domain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package outbound
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/base64"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -225,3 +226,117 @@ func TestMailerPermanentFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
<-done
|
<-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,17 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
|
|||||||
lim: make(map[uint]*userWindow),
|
lim: make(map[uint]*userWindow),
|
||||||
batch: 50,
|
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
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user