From 8e93faf25297e0b8abc182a60503e1028b520c70 Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Fri, 4 Jul 2025 16:15:03 -0400 Subject: [PATCH] Add support for relaying messages to a relay MTA --- config.go | 13 +++++ smtp.go | 9 +++- smtp/relay_smtp.go | 10 +++- smtp/relay_test.go | 116 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/config.go b/config.go index 961d2cb..7ccd2c5 100644 --- a/config.go +++ b/config.go @@ -18,6 +18,9 @@ type Config struct { Hostname string Servers []Server + + // Outbound controls how sent messages are delivered to their destination. + Outbound *OutboundDelivery } const MailboxAccount = "mailbox@" @@ -40,6 +43,16 @@ type Server struct { BlockedAddresses []string } +type OutboundDelivery struct { + // Relay specifes an intermediate SMTP relay server. + Relay *OutboundSMTPRelay +} + +type OutboundSMTPRelay struct { + Hostname, Port string + Username, Password string +} + func (c Config) GetTLSConfig() (*tls.Config, error) { certs := make([]tls.Certificate, 0, len(c.Servers)) for _, server := range c.Servers { diff --git a/smtp.go b/smtp.go index 5823eeb..5931232 100644 --- a/smtp.go +++ b/smtp.go @@ -29,7 +29,14 @@ func runSMTPServer(config Config, log *zap.Logger) <-chan ServerControlMessage { controlChan: make(chan ServerControlMessage), log: log.With(zap.String("server", "smtp")), } - server.mta = smtp.NewSMTPMTA(&server, server.log) + if o := config.Outbound; o != nil { + if r := o.Relay; r != nil { + server.mta = smtp.NewSMTPRelayMTA(&server, server.log, r.Hostname, r.Port, r.Username, r.Port) + } + } + if server.mta == nil { + server.mta = smtp.NewSMTPMTA(&server, server.log) + } go server.run() return server.controlChan } diff --git a/smtp/relay_smtp.go b/smtp/relay_smtp.go index 15898ac..8870bd2 100644 --- a/smtp/relay_smtp.go +++ b/smtp/relay_smtp.go @@ -17,6 +17,7 @@ import ( type smtpMTA struct { mta + tls *tls.Config } // NewSMTPMTA creates an MTA that directly delivers messages to the recipient's @@ -27,12 +28,14 @@ func NewSMTPMTA(server Server, log *zap.Logger) MTA { server: server, log: log, }, + tls: &tls.Config{}, } } func (m *smtpMTA) RelayMessage(env Envelope) { for _, rcptTo := range env.RcptTo { sendLog := m.log.With(zap.String("address", rcptTo.Address), zap.String("id", env.ID)) + sendLog.Info("sending message directly to destination") domain := DomainForAddress(rcptTo) mx, err := net.LookupMX(domain) @@ -72,7 +75,8 @@ func (m *smtpMTA) dialSMTP(host string, port string, requireTLS bool) (*smtp.Cli } if hasTls, _ := c.Extension("STARTTLS"); hasTls { - config := &tls.Config{ServerName: host} + config := m.tls.Clone() + config.ServerName = host if err = c.StartTLS(config); err != nil { return nil, fmt.Errorf("failed to STARTTLS: %v", err) } @@ -124,6 +128,8 @@ type smtpRelayMTA struct { password string } +// NewSMTPRelayMTA creates a MTA that sends outbound messages through an +// intermediate SMTP relay. The relay must support TLS. func NewSMTPRelayMTA(server Server, log *zap.Logger, host, port, user, password string) MTA { return &smtpRelayMTA{ smtpMTA: smtpMTA{ @@ -131,6 +137,7 @@ func NewSMTPRelayMTA(server Server, log *zap.Logger, host, port, user, password server: server, log: log, }, + tls: &tls.Config{}, }, host: host, port: port, @@ -141,6 +148,7 @@ func NewSMTPRelayMTA(server Server, log *zap.Logger, host, port, user, password func (m *smtpRelayMTA) RelayMessage(env Envelope) { log := m.log.With(zap.String("host", net.JoinHostPort(m.host, m.port))) + log.Info("sending message via relay") c, err := m.dialSMTP(m.host, m.port, true) if err != nil { diff --git a/smtp/relay_test.go b/smtp/relay_test.go index 8395717..de556a6 100644 --- a/smtp/relay_test.go +++ b/smtp/relay_test.go @@ -8,6 +8,7 @@ package smtp import ( "bytes" + "crypto/tls" "fmt" "io/ioutil" "mime" @@ -30,7 +31,7 @@ func (s *deliveryServer) DeliverMessage(env Envelope) *ReplyLine { return nil } -func TestRelayRoundTrip(t *testing.T) { +func TestDeliveryRoundTrip(t *testing.T) { s := &deliveryServer{ testServer: testServer{domain: "receive.net"}, } @@ -71,6 +72,119 @@ func TestRelayRoundTrip(t *testing.T) { } } +const ( + relayTestSender = "from@sender.org" + relayTestReceiver = "to@receive.net" + relayTestData = "~~~Message~~~\n" +) + +type relayTestMode int + +const ( + relayTestSuccess relayTestMode = iota + relayTestDialFail + relayTestAuthFail + relayTestTLSBad + relayTestTLSNotSupported +) + +// relayTest runs a test using an SMTP relay MTA and returns the messages +// deliverd to the destination and the delivery failure messages. +func relayTest(t *testing.T, mode relayTestMode) ([]Envelope, []Envelope) { + log, _ := zap.NewDevelopment() + tlsConfig := getTLSConfig(t) + if mode == relayTestTLSNotSupported { + tlsConfig = nil + } + s := &deliveryServer{ + testServer: testServer{ + domain: "receive.net", + tlsConfig: tlsConfig, + userAuth: &userAuth{ + authc: "relay@receive.net", + passwd: "realy2go", + }, + }, + } + l := runServer(t, s) + defer l.Close() + + env := Envelope{ + MailFrom: mail.Address{Address: relayTestSender}, + RcptTo: []mail.Address{{Address: relayTestReceiver}}, + Data: []byte(relayTestData), + ID: "ididid", + } + + os := &deliveryServer{} + host, port, _ := net.SplitHostPort(l.Addr().String()) + if mode == relayTestDialFail { + port = "1" + } + passwd := s.testServer.passwd + if mode == relayTestAuthFail { + passwd = "bad" + } + mta := NewSMTPRelayMTA(os, log, host, port, s.testServer.authc, passwd).(*smtpRelayMTA) + if mode != relayTestTLSBad { + mta.tls = &tls.Config{InsecureSkipVerify: true} + } + mta.RelayMessage(env) + + return s.messages, os.messages +} + +func TestRelayRoundTrip(t *testing.T) { + msgs, fails := relayTest(t, relayTestSuccess) + + if want, got := 1, len(msgs); want != got { + t.Errorf("Want %d message to be delivered, got %d", want, got) + return + } + if want, got := 0, len(fails); want != got { + t.Errorf("Want %d error receipts, got %d", want, got) + t.Logf("First body: %s", string(fails[0].Data)) + return + } + + received := msgs[0] + + if want, got := relayTestSender, received.MailFrom.Address; want != got { + t.Errorf("Want MailFrom %s, got %s", want, got) + } + if want, got := 1, len(received.RcptTo); want != got { + t.Errorf("Want %d RcptTo, got %d", want, got) + return + } + if want, got := relayTestReceiver, received.RcptTo[0].Address; want != got { + t.Errorf("Want RcptTo %s, got %s", want, got) + } + + if data := string(received.Data); !strings.HasSuffix(data, relayTestData) { + t.Errorf("Delivered message does not match relayed one. Delivered=%q Relayed=%q", relayTestData, data) + } +} + +func TestRelayFailures(t *testing.T) { + modes := []relayTestMode{ + relayTestAuthFail, + relayTestDialFail, + relayTestTLSBad, + relayTestTLSNotSupported, + } + for _, mode := range modes { + t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) { + msgs, fails := relayTest(t, mode) + if want, got := 0, len(msgs); want != got { + t.Errorf("Expected %d successful delivery, got %d", want, got) + } + if want, got := 1, len(fails); want != got { + t.Errorf("Expected %d delivery failure notifications, got %d", want, got) + } + }) + } +} + func TestDeliveryFailureMessage(t *testing.T) { s := &deliveryServer{} -- 2.52.0