144 lines
4.3 KiB
Go
144 lines
4.3 KiB
Go
package usecase
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const relayDialTimeout = 15 * time.Second
|
|
|
|
// RelayConfig is the smarthost of PLAN.md section 10.4, used when the host's
|
|
// outbound port 25 is blocked. In this mode the relay takes responsibility for
|
|
// transport security to the final destination, so DANE and MTA-STS are not
|
|
// evaluated here - they would only describe the wrong hop.
|
|
type RelayConfig struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
// RootCAs trusts an internal relay presenting a certificate from a
|
|
// private CA. Left nil, the system trust store is used. There is
|
|
// deliberately no option to skip verification: the credentials sent over
|
|
// this connection are worth protecting.
|
|
RootCAs *x509.CertPool
|
|
}
|
|
|
|
// Configured reports whether delivery should go through the relay.
|
|
func (c RelayConfig) Configured() bool {
|
|
return c.Host != ""
|
|
}
|
|
|
|
// Address renders the dial target, defaulting to the submission port.
|
|
func (c RelayConfig) Address() string {
|
|
port := c.Port
|
|
if port == 0 {
|
|
port = 587
|
|
}
|
|
return net.JoinHostPort(c.Host, strconv.Itoa(port))
|
|
}
|
|
|
|
// SpfInclude renders the mechanism to add to the domain's SPF record. Without
|
|
// it the relay's own IP is not authorised to send for the domain and every
|
|
// message it forwards fails SPF (PLAN.md section 10.4).
|
|
func (c RelayConfig) SpfInclude() string {
|
|
if !c.Configured() {
|
|
return ""
|
|
}
|
|
// A relay host is normally a subdomain of the provider's sending
|
|
// infrastructure; including its organisational domain is what the
|
|
// provider documents. The host itself is used when it cannot be reduced.
|
|
host := strings.TrimSuffix(strings.ToLower(c.Host), ".")
|
|
labels := strings.Split(host, ".")
|
|
if len(labels) > 2 {
|
|
host = strings.Join(labels[len(labels)-2:], ".")
|
|
}
|
|
return "include:" + host
|
|
}
|
|
|
|
// authFor selects the SASL mechanism to use against the relay. PLAIN is
|
|
// preferred, and CRAM-MD5 is deliberately not offered: it requires the server
|
|
// to store a reversible secret.
|
|
func (c RelayConfig) authFor(serverName string) smtp.Auth {
|
|
if c.Username == "" {
|
|
return nil
|
|
}
|
|
return smtp.PlainAuth("", c.Username, c.Password, serverName)
|
|
}
|
|
|
|
// relayTLSConfig requires a valid certificate. Unlike opportunistic delivery
|
|
// to an arbitrary MX, the relay is a host we chose and configured, so there is
|
|
// no reason to accept an unverified certificate - and the credentials sent
|
|
// over this connection make it worth refusing.
|
|
func relayTLSConfig(relay RelayConfig) *tls.Config {
|
|
return &tls.Config{
|
|
ServerName: relay.Host,
|
|
MinVersion: tls.VersionTLS12,
|
|
RootCAs: relay.RootCAs,
|
|
}
|
|
}
|
|
|
|
// deliverViaRelay hands one message to the smarthost.
|
|
func (w *OutboundWorkerUseCase) deliverViaRelay(from, to string, payload []byte) error {
|
|
relay := w.relay
|
|
addr := relay.Address()
|
|
|
|
conn, err := net.DialTimeout("tcp", addr, relayDialTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("dial relay %s: %w", addr, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
client, err := smtp.NewClient(conn, relay.Host)
|
|
if err != nil {
|
|
return fmt.Errorf("relay handshake: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if err := client.Hello(w.localHost); err != nil {
|
|
return fmt.Errorf("relay EHLO failed: %w", err)
|
|
}
|
|
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
if err := client.StartTLS(relayTLSConfig(relay)); err != nil {
|
|
return fmt.Errorf("relay STARTTLS failed: %w", err)
|
|
}
|
|
} else if relay.Username != "" {
|
|
// Sending the relay credentials over a cleartext link would leak them
|
|
// to anyone on the path, which is worse than deferring the message.
|
|
return fmt.Errorf("relay %s does not offer STARTTLS and credentials must not be sent in the clear", addr)
|
|
}
|
|
|
|
if auth := relay.authFor(relay.Host); auth != nil {
|
|
if err := client.Auth(auth); err != nil {
|
|
return fmt.Errorf("relay authentication failed: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := client.Mail(from); err != nil {
|
|
return fmt.Errorf("relay MAIL FROM failed: %w", err)
|
|
}
|
|
if err := client.Rcpt(to); err != nil {
|
|
return fmt.Errorf("relay RCPT TO failed: %w", err)
|
|
}
|
|
|
|
writer, err := client.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("relay DATA command failed: %w", err)
|
|
}
|
|
if _, err := writer.Write(payload); err != nil {
|
|
writer.Close()
|
|
return fmt.Errorf("relay write DATA failed: %w", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return fmt.Errorf("relay close DATA failed: %w", err)
|
|
}
|
|
|
|
return client.Quit()
|
|
}
|