git » chasquid » commit 0f2ffc8

WIP: Add smarthost support

author Alberto Bertogli
2020-09-22 00:52:44 UTC
committer Alberto Bertogli
2022-11-12 18:54:59 UTC
parent 4efe8db9476e2e924920b6a92ac67c18b7af5997

WIP: Add smarthost support

WORK IN PROGRESS -- WORK IN PROGRESS -- WORK IN PROGRESS

This patch adds support for delivering mail via a smarthost.

In this mode, all accepted mail gets delivered through an SMTP
connection to a specific host, statically configured.

.mkdocs.yml +1 -1
chasquid.go +24 -9
docs/man/chasquid.conf.5.pod +11 -0
docs/monitoring.md +6 -0
docs/smarthost.md +41 -0
etc/chasquid/chasquid.conf +10 -0
internal/config/config.go +15 -0
internal/config/config.pb.go +24 -9
internal/config/config.proto +7 -0
internal/courier/smarthost.go +145 -0
internal/courier/smarthost_test.go +220 -0
test/t-20-smarthost/A/chasquid.conf +17 -0
test/t-20-smarthost/A/domains/srv-A/aliases +0 -0
test/t-20-smarthost/B/chasquid.conf +10 -0
test/t-20-smarthost/B/domains/srv-B/aliases +0 -0
test/t-20-smarthost/C/chasquid.conf +10 -0
test/t-20-smarthost/C/domains/srv-C/aliases +0 -0
test/t-20-smarthost/content +9 -0
test/t-20-smarthost/hosts +3 -0
test/t-20-smarthost/msmtprc +14 -0
test/t-20-smarthost/run.sh +60 -0
test/t-20-smarthost/zones +6 -0

diff --git a/.mkdocs.yml b/.mkdocs.yml
index f1e1766..adfd0e9 100644
--- a/.mkdocs.yml
+++ b/.mkdocs.yml
@@ -31,7 +31,7 @@ nav:
     - flow.md
     - monitoring.md
     - sec-levels.md
+    - smarthost.md
     - tests.md
     - relnotes.md
     - knownissues.md
-
diff --git a/chasquid.go b/chasquid.go
index 4745f64..dcfb310 100644
--- a/chasquid.go
+++ b/chasquid.go
@@ -12,6 +12,7 @@ import (
 	"io/ioutil"
 	"math/rand"
 	"net"
+	"net/url"
 	"os"
 	"os/signal"
 	"path/filepath"
@@ -160,16 +161,30 @@ func main() {
 	}
 	go stsCache.PeriodicallyRefresh(context.Background())
 
-	localC := &courier.MDA{
-		Binary:  conf.MailDeliveryAgentBin,
-		Args:    conf.MailDeliveryAgentArgs,
-		Timeout: 30 * time.Second,
-	}
-	remoteC := &courier.SMTP{
-		HelloDomain: conf.Hostname,
-		Dinfo:       dinfo,
-		STSCache:    stsCache,
+	var localC, remoteC courier.Courier
+	if conf.SmarthostUrl != "" {
+		smurl, err := url.Parse(conf.SmarthostUrl)
+		if err != nil {
+			log.Fatalf("Invalid smarthost url: %v", err)
+		}
+		remoteC = &courier.SmartHost{
+			HelloDomain: conf.Hostname,
+			URL:         *smurl,
+		}
+		localC = remoteC
+	} else {
+		localC = &courier.MDA{
+			Binary:  conf.MailDeliveryAgentBin,
+			Args:    conf.MailDeliveryAgentArgs,
+			Timeout: 30 * time.Second,
+		}
+		remoteC = &courier.SMTP{
+			HelloDomain: conf.Hostname,
+			Dinfo:       dinfo,
+			STSCache:    stsCache,
+		}
 	}
+
 	s.InitQueue(conf.DataDir+"/queue", localC, remoteC)
 
 	// Load the addresses and listeners.
diff --git a/docs/man/chasquid.conf.5.pod b/docs/man/chasquid.conf.5.pod
index 436d5e3..8453e8a 100644
--- a/docs/man/chasquid.conf.5.pod
+++ b/docs/man/chasquid.conf.5.pod
@@ -122,6 +122,17 @@ This allows deploying chasquid behind a HAProxy server, as the address
 information is preserved, and SPF checks can be performed properly.
 Default: C<false>.
 
+=item B<smarthost_url> (string):
+
+Smarthost URL. If set, we will send all received email to this location,
+including local mail.
+
+It is of the form C<smtp://user:password@host:port> for SMTP (and STARTTLS
+will be forcefully negotiated), or C<tls://user:password@host:port> for SMTP
+over TLS (usually port 465).
+
+B<EXPERIMENTAL> for now, can change in backwards-incompatible ways.
+
 =back
 
 =head1 SEE ALSO
diff --git a/docs/monitoring.md b/docs/monitoring.md
index 21db2a2..c8b3cc4 100644
--- a/docs/monitoring.md
+++ b/docs/monitoring.md
@@ -76,6 +76,12 @@ List of exported variables:
   count of STS security checks on outgoing connections, by result (pass/fail).
 - **chasquid/smtpOut/tlsCount** (status -> counter)  
   count of TLS status (insecure TLS/secure TLS/plain) on outgoing connections.
+- **chasquid/smarthostOut/attempts** (counter)  
+  count of attempts to deliver via smarthost.
+- **chasquid/smarthostOut/errors** (reason -> counter)  
+  count of smarthost delivery errors, per reason.
+- **chasquid/smarthostOut/success** (counter)  
+  count of successful delivering via smarthost.
 - **chasquid/sourceDateStr** (string)  
   timestamp when the binary was built, in human readable format.
 - **chasquid/sourceDateTimestamp** (int)  
diff --git a/docs/smarthost.md b/docs/smarthost.md
new file mode 100644
index 0000000..0ee34c8
--- /dev/null
+++ b/docs/smarthost.md
@@ -0,0 +1,41 @@
+
+# Smarthost client mode
+
+As of version 1.6 (2020-XX), [chasquid] supports operating as a [smarthost]
+client.
+
+In this mode, chasquid will deliver all accepted mail (both local and remote)
+to a single specific host (the *smarthost* server).
+
+## Status
+
+It is **EXPERIMENTAL** for now. The configuration options and behaviour can
+change in backwards-incompatible ways.
+
+
+## Security
+
+chasquid will always negotiate TLS on the connection to the smarthost, and
+expects a valid certificate.
+
+If TLS is not available, or the certificate is not valid, the mail will remain
+in the queue and will not be delivered.
+
+
+## Configuring
+
+Add the following line to `/etc/chasquid/chasquid.conf`:
+
+```
+smarthost_url: "smtp://user:password@server:587"
+```
+
+Replace `user` and `password` with the credentials used to authenticate to the
+smarthost server, and `server:587` with the server address, including port.
+
+You can also use the `tls` scheme for direct TLS connections (usually on port
+465).
+
+
+[chasquid]: https://blitiri.com.ar/p/chasquid
+[smarthost]: https://en.wikipedia.org/wiki/Smart_host
diff --git a/etc/chasquid/chasquid.conf b/etc/chasquid/chasquid.conf
index cd18579..a3cba76 100644
--- a/etc/chasquid/chasquid.conf
+++ b/etc/chasquid/chasquid.conf
@@ -101,3 +101,13 @@ submission_over_tls_address: ":465"
 # properly.
 # Default: false
 #haproxy_incoming: false
+
+# Smarthost URL.
+# If set, we will send all received email to this location, including local
+# mail.
+# It is of the form "smtp://user:password@host:port" for SMTP (and STARTTLS
+# will be forcefully negotiated), or "tls://user:password@host:port" for SMTP
+# over TLS (usually port 465).
+#
+# EXPERIMENTAL - Can change in backwards-incompatible ways.
+#smarthost_url: ""
diff --git a/internal/config/config.go b/internal/config/config.go
index efde353..ca44049 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,6 +7,7 @@ package config
 import (
 	"fmt"
 	"io/ioutil"
+	"net/url"
 	"os"
 
 	"blitiri.com.ar/go/log"
@@ -127,6 +128,9 @@ func override(c, o *Config) {
 	if o.HaproxyIncoming {
 		c.HaproxyIncoming = true
 	}
+	if o.SmarthostUrl != "" {
+		c.SmarthostUrl = o.SmarthostUrl
+	}
 }
 
 // LogConfig logs the given configuration, in a human-friendly way.
@@ -154,4 +158,15 @@ func LogConfig(c *Config) {
 	log.Infof("  Dovecot auth: %v (%q, %q)",
 		c.DovecotAuth, c.DovecotUserdbPath, c.DovecotClientPath)
 	log.Infof("  HAProxy incoming: %v", c.HaproxyIncoming)
+
+	// Avoid logging the password for the smarthost URL.
+	smurl, err := url.Parse(c.SmarthostUrl)
+	if err == nil {
+		if smurl.User != nil {
+			smurl.User = url.User(smurl.User.Username())
+		}
+		log.Infof("  Smarthost: %s", smurl)
+	} else {
+		log.Infof("  Smarthost: <invalid URL>")
+	}
 }
diff --git a/internal/config/config.pb.go b/internal/config/config.pb.go
index cf07bea..849b981 100644
--- a/internal/config/config.pb.go
+++ b/internal/config/config.pb.go
@@ -107,6 +107,12 @@ type Config struct {
 	// This allows deploying chasquid behind a HAProxy server, as the
 	// address information is preserved.
 	HaproxyIncoming bool `protobuf:"varint,16,opt,name=haproxy_incoming,json=haproxyIncoming,proto3" json:"haproxy_incoming,omitempty"`
+	// Smarthost URL. If set, we will send all received email to this
+	// location, including local mail.
+	// It is of the form "smtp://user:password@host:port" for SMTP (and
+	// STARTTLS will be forcefully negotiated), or
+	// "tls://user:password@host:port" for SMTP over TLS (usually port 465).
+	SmarthostUrl string `protobuf:"bytes,17,opt,name=smarthost_url,json=smarthostUrl,proto3" json:"smarthost_url,omitempty"`
 }
 
 func (x *Config) Reset() {
@@ -253,11 +259,18 @@ func (x *Config) GetHaproxyIncoming() bool {
 	return false
 }
 
+func (x *Config) GetSmarthostUrl() string {
+	if x != nil {
+		return x.SmarthostUrl
+	}
+	return ""
+}
+
 var File_config_proto protoreflect.FileDescriptor
 
 var file_config_proto_rawDesc = []byte{
-	0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf4,
-	0x05, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73,
+	0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x99,
+	0x06, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73,
 	0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73,
 	0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x10, 0x6d, 0x61, 0x78, 0x5f, 0x64, 0x61, 0x74,
 	0x61, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x6d, 0x62, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
@@ -301,13 +314,15 @@ var file_config_proto_rawDesc = []byte{
 	0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x50, 0x61, 0x74, 0x68,
 	0x12, 0x29, 0x0a, 0x10, 0x68, 0x61, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x6e, 0x63, 0x6f,
 	0x6d, 0x69, 0x6e, 0x67, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x68, 0x61, 0x70, 0x72,
-	0x6f, 0x78, 0x79, 0x49, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x42, 0x14, 0x0a, 0x12, 0x5f,
-	0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x5f, 0x73, 0x65, 0x70, 0x61, 0x72, 0x61, 0x74, 0x6f, 0x72,
-	0x73, 0x42, 0x12, 0x0a, 0x10, 0x5f, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x63, 0x68, 0x61, 0x72, 0x61,
-	0x63, 0x74, 0x65, 0x72, 0x73, 0x42, 0x2c, 0x5a, 0x2a, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72, 0x69,
-	0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73, 0x71,
-	0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x6f, 0x78, 0x79, 0x49, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x12, 0x23, 0x0a, 0x0d, 0x73,
+	0x6d, 0x61, 0x72, 0x74, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x11, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0c, 0x73, 0x6d, 0x61, 0x72, 0x74, 0x68, 0x6f, 0x73, 0x74, 0x55, 0x72, 0x6c,
+	0x42, 0x14, 0x0a, 0x12, 0x5f, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x5f, 0x73, 0x65, 0x70, 0x61,
+	0x72, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x42, 0x12, 0x0a, 0x10, 0x5f, 0x64, 0x72, 0x6f, 0x70, 0x5f,
+	0x63, 0x68, 0x61, 0x72, 0x61, 0x63, 0x74, 0x65, 0x72, 0x73, 0x42, 0x2c, 0x5a, 0x2a, 0x62, 0x6c,
+	0x69, 0x74, 0x69, 0x72, 0x69, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f,
+	0x63, 0x68, 0x61, 0x73, 0x71, 0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61,
+	0x6c, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
diff --git a/internal/config/config.proto b/internal/config/config.proto
index b7a55b9..01a6462 100644
--- a/internal/config/config.proto
+++ b/internal/config/config.proto
@@ -100,4 +100,11 @@ message Config {
 	// This allows deploying chasquid behind a HAProxy server, as the
 	// address information is preserved.
 	bool haproxy_incoming = 16;
+
+	// Smarthost URL. If set, we will send all received email to this
+	// location, including local mail.
+	// It is of the form "smtp://user:password@host:port" for SMTP (and
+	// STARTTLS will be forcefully negotiated), or
+	// "tls://user:password@host:port" for SMTP over TLS (usually port 465).
+	string smarthost_url = 17;
 }
diff --git a/internal/courier/smarthost.go b/internal/courier/smarthost.go
new file mode 100644
index 0000000..6d8ea55
--- /dev/null
+++ b/internal/courier/smarthost.go
@@ -0,0 +1,145 @@
+package courier
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"net"
+	netsmtp "net/smtp"
+	"net/url"
+	"time"
+
+	"blitiri.com.ar/go/chasquid/internal/expvarom"
+	"blitiri.com.ar/go/chasquid/internal/smtp"
+	"blitiri.com.ar/go/chasquid/internal/trace"
+)
+
+var (
+	// Timeouts for smarthost delivery.
+	shDialTimeout  = 1 * time.Minute
+	shTotalTimeout = 10 * time.Minute
+)
+
+// Exported variables.
+var (
+	shAttempts = expvarom.NewInt("chasquid/smarthostOut/attempts",
+		"count of attempts to deliver via smarthost")
+	shErrors = expvarom.NewMap("chasquid/smarthostOut/errors",
+		"reason", "count of smarthost delivery errors, per reason")
+	shSuccess = expvarom.NewInt("chasquid/smarthostOut/success",
+		"count of successful delivering via smarthost")
+)
+
+// SmartHost delivers remote mail via smarthost relaying.
+type SmartHost struct {
+	HelloDomain string
+	URL         url.URL
+
+	// For testing.
+	rootCAs *x509.CertPool
+}
+
+// Deliver an email. On failures, returns an error, and whether or not it is
+// permanent.
+func (s *SmartHost) Deliver(from string, to string, data []byte) (error, bool) {
+	tr := trace.New("Courier.SmartHost", to)
+	defer tr.Finish()
+	tr.Debugf("%s  ->  %s", from, to)
+	shAttempts.Add(1)
+
+	conn, onTLS, err := s.dial()
+	if err != nil {
+		shErrors.Add("dial", 1)
+		return tr.Errorf("Could not dial %q: %v", s.URL.Host, err), false
+	}
+
+	defer conn.Close()
+	conn.SetDeadline(time.Now().Add(shTotalTimeout))
+
+	host, _, _ := net.SplitHostPort(s.URL.Host)
+
+	c, err := smtp.NewClient(conn, host)
+	if err != nil {
+		shErrors.Add("client", 1)
+		return tr.Errorf("Error creating client: %v", err), false
+	}
+
+	if err = c.Hello(s.HelloDomain); err != nil {
+		shErrors.Add("hello", 1)
+		return tr.Errorf("Error saying hello: %v", err), false
+	}
+
+	if !onTLS {
+		if ok, _ := c.Extension("STARTTLS"); !ok {
+			shErrors.Add("starttls-support", 1)
+			return tr.Errorf("Server does not support STARTTLS"), false
+		}
+
+		config := &tls.Config{
+			ServerName: host,
+			RootCAs:    s.rootCAs,
+		}
+		if err = c.StartTLS(config); err != nil {
+			shErrors.Add("starttls-exchange", 1)
+			return tr.Errorf("Error in STARTTLS: %v", err), false
+		}
+	}
+
+	if s.URL.User != nil {
+		user := s.URL.User.Username()
+		password, _ := s.URL.User.Password()
+		auth := netsmtp.PlainAuth("", user, password, host)
+		if err = c.Auth(auth); err != nil {
+			shErrors.Add("auth", 1)
+			return tr.Errorf("AUTH error: %v", err), false
+		}
+	}
+
+	// smtp.Client.Mail will add the <> for us when the address is empty.
+	if from == "<>" {
+		from = ""
+	}
+
+	if err = c.MailAndRcpt(from, to); err != nil {
+		shErrors.Add("mail", 1)
+		return tr.Errorf("MAIL+RCPT %v", err), smtp.IsPermanent(err)
+	}
+
+	w, err := c.Data()
+	if err != nil {
+		shErrors.Add("data", 1)
+		return tr.Errorf("DATA %v", err), smtp.IsPermanent(err)
+	}
+	_, err = w.Write(data)
+	if err != nil {
+		shErrors.Add("dataw", 1)
+		return tr.Errorf("DATA writing: %v", err), smtp.IsPermanent(err)
+	}
+
+	err = w.Close()
+	if err != nil {
+		shErrors.Add("close", 1)
+		return tr.Errorf("DATA closing %v", err), smtp.IsPermanent(err)
+	}
+
+	_ = c.Quit()
+	tr.Debugf("done")
+	shSuccess.Add(1)
+
+	return nil, false
+}
+
+func (s *SmartHost) dial() (conn net.Conn, onTLS bool, err error) {
+	dialer := &net.Dialer{Timeout: shDialTimeout}
+
+	if s.URL.Scheme == "tls" {
+		onTLS = true
+		config := &tls.Config{
+			RootCAs: s.rootCAs,
+		}
+		conn, err = tls.DialWithDialer(dialer, "tcp", s.URL.Host, config)
+	} else {
+		onTLS = false
+		conn, err = dialer.Dial("tcp", s.URL.Host)
+	}
+	return
+}
diff --git a/internal/courier/smarthost_test.go b/internal/courier/smarthost_test.go
new file mode 100644
index 0000000..68aa6aa
--- /dev/null
+++ b/internal/courier/smarthost_test.go
@@ -0,0 +1,220 @@
+package courier
+
+import (
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+)
+
+func newSmartHost(t *testing.T, addr string) *SmartHost {
+	return &SmartHost{
+		HelloDomain: "hello",
+		URL: url.URL{
+			Scheme: "smtp",
+			Host:   addr,
+		},
+	}
+}
+
+func TestSmartHost(t *testing.T) {
+	// Shorten the total timeout, so the test fails quickly if the protocol
+	// gets stuck.
+	shTotalTimeout = 3 * time.Second
+
+	responses := map[string]string{
+		"_welcome":   "220 welcome\n",
+		"EHLO hello": "250-ehlo ok\n250 STARTTLS AUTH HELP\n",
+		"STARTTLS":   "220 tls ok\n",
+		"_STARTTLS":  "ok",
+
+		// Auth corresponds to the user and password below.
+		"AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=": "235 auth ok\n",
+
+		"MAIL FROM:<me@me>": "250 mail ok\n",
+		"RCPT TO:<to@to>":   "250 rcpt ok\n",
+		"DATA":              "354 send data\n",
+		"_DATA":             "250 data ok\n",
+		"QUIT":              "250 quit ok\n",
+	}
+	srv := newFakeServer(t, responses)
+
+	sh := newSmartHost(t, srv.addr)
+	sh.URL.User = url.UserPassword("user", "password")
+	sh.rootCAs = srv.rootCA()
+	err, _ := sh.Deliver("me@me", "to@to", []byte("data"))
+	if err != nil {
+		t.Errorf("deliver failed: %v", err)
+	}
+
+	srv.wg.Wait()
+}
+
+func TestSmartHostBadAuth(t *testing.T) {
+	// Shorten the total timeout, so the test fails quickly if the protocol
+	// gets stuck.
+	shTotalTimeout = 3 * time.Second
+
+	responses := map[string]string{
+		"_welcome":   "220 welcome\n",
+		"EHLO hello": "250-ehlo ok\n250-STARTTLS\n250 AUTH PLAIN\n",
+		"STARTTLS":   "220 tls ok\n",
+		"_STARTTLS":  "ok",
+
+		// Auth corresponds to the user and password below.
+		"AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=": "454 auth error\n",
+
+		// The client will use an "*" to abort the auth on errors.
+		"*": "501 invalid command\n",
+
+		"QUIT": "250 quit ok\n",
+	}
+	srv := newFakeServer(t, responses)
+
+	sh := newSmartHost(t, srv.addr)
+	sh.URL.User = url.UserPassword("user", "password")
+	sh.rootCAs = srv.rootCA()
+	err, _ := sh.Deliver("me@me", "to@to", []byte("data"))
+	if !strings.HasPrefix(err.Error(), "AUTH error: 454 auth error") {
+		t.Errorf("expected error in AUTH, got %q", err)
+	}
+
+	srv.wg.Wait()
+}
+
+func TestSmartHostBadCert(t *testing.T) {
+	// Shorten the total timeout, so the test fails quickly if the protocol
+	// gets stuck.
+	shTotalTimeout = 3 * time.Second
+
+	responses := map[string]string{
+		"_welcome":   "220 welcome\n",
+		"EHLO hello": "250-ehlo ok\n250 STARTTLS\n",
+		"STARTTLS":   "220 tls ok\n",
+		"_STARTTLS":  "ok",
+	}
+	srv := newFakeServer(t, responses)
+
+	sh := newSmartHost(t, srv.addr)
+	// We do NOT set the root CA to our test server's certificate, so we
+	// expect the STARTTLS negotiation to fail.
+	err, _ := sh.Deliver("me@me", "to@to", []byte("data"))
+	if !strings.HasPrefix(err.Error(), "Error in STARTTLS:") {
+		t.Errorf("expected error in STARTTLS, got %q", err)
+	}
+
+	srv.wg.Wait()
+}
+
+func TestSmartHostErrors(t *testing.T) {
+	// Shorten the total timeout, so the test fails quickly if the protocol
+	// gets stuck.
+	shTotalTimeout = 1 * time.Second
+
+	cases := []struct {
+		responses map[string]string
+		errPrefix string
+	}{
+		// First test: hang response, should fail due to timeout.
+		{
+			map[string]string{"_welcome": "220 no newline"},
+			"",
+		},
+
+		// No STARTTLS support.
+		{
+			map[string]string{
+				"_welcome":   "220 rcpt to not allowed\n",
+				"EHLO hello": "250-ehlo ok\n250 HELP\n",
+			},
+			"Server does not support STARTTLS",
+		},
+
+		// MAIL FROM not allowed.
+		{
+			map[string]string{
+				"_welcome":          "220 mail from not allowed\n",
+				"EHLO hello":        "250-ehlo ok\n250 STARTTLS\n",
+				"STARTTLS":          "220 tls ok\n",
+				"_STARTTLS":         "ok",
+				"MAIL FROM:<me@me>": "501 mail error\n",
+			},
+			"MAIL+RCPT 501 mail error",
+		},
+
+		// RCPT TO not allowed.
+		{
+			map[string]string{
+				"_welcome":          "220 rcpt to not allowed\n",
+				"EHLO hello":        "250-ehlo ok\n250 STARTTLS\n",
+				"STARTTLS":          "220 tls ok\n",
+				"_STARTTLS":         "ok",
+				"MAIL FROM:<me@me>": "250 mail ok\n",
+				"RCPT TO:<to@to>":   "501 rcpt error\n",
+			},
+			"MAIL+RCPT 501 rcpt error",
+		},
+
+		// DATA error.
+		{
+			map[string]string{
+				"_welcome":          "220 data error\n",
+				"EHLO hello":        "250-ehlo ok\n250 STARTTLS\n",
+				"STARTTLS":          "220 tls ok\n",
+				"_STARTTLS":         "ok",
+				"MAIL FROM:<me@me>": "250 mail ok\n",
+				"RCPT TO:<to@to>":   "250 rcpt ok\n",
+				"DATA":              "554 data error\n",
+			},
+			"DATA 554 data error",
+		},
+
+		// DATA response error.
+		{
+			map[string]string{
+				"_welcome":          "220 data error\n",
+				"EHLO hello":        "250-ehlo ok\n250 STARTTLS\n",
+				"STARTTLS":          "220 tls ok\n",
+				"_STARTTLS":         "ok",
+				"MAIL FROM:<me@me>": "250 mail ok\n",
+				"RCPT TO:<to@to>":   "250 rcpt ok\n",
+				"DATA":              "354 send data\n",
+				"_DATA":             "551 data response error\n",
+			},
+			"DATA closing 551 data response error",
+		},
+	}
+
+	for _, c := range cases {
+		srv := newFakeServer(t, c.responses)
+		sh := newSmartHost(t, srv.addr)
+		sh.rootCAs = srv.rootCA()
+
+		err, _ := sh.Deliver("me@me", "to@to", []byte("data"))
+		if err == nil {
+			t.Errorf("deliver not failed in case %q: %v",
+				c.responses["_welcome"], err)
+			continue
+		}
+		t.Logf("failed as expected: %v", err)
+
+		if !strings.HasPrefix(err.Error(), c.errPrefix) {
+			t.Errorf("expected error prefix %q, got %q",
+				c.errPrefix, err)
+		}
+
+		srv.wg.Wait()
+	}
+}
+
+func TestSmartHostDialError(t *testing.T) {
+	sh := newSmartHost(t, "localhost:1")
+	err, permanent := sh.Deliver("me@me", "to@to", []byte("data"))
+	if err == nil {
+		t.Errorf("delivery worked, expected failure")
+	}
+	if permanent {
+		t.Errorf("expected transient failure, got permanent (%v)", err)
+	}
+	t.Logf("got transient failure, as expected: %v", err)
+}
diff --git a/test/t-20-smarthost/A/chasquid.conf b/test/t-20-smarthost/A/chasquid.conf
new file mode 100644
index 0000000..4831b90
--- /dev/null
+++ b/test/t-20-smarthost/A/chasquid.conf
@@ -0,0 +1,17 @@
+smtp_address: ":1025"
+submission_address: ":1587"
+submission_over_tls_address: ":1465"
+monitoring_address: ":1099"
+
+# We don't expect this to be used, pick something that will error to ease
+# troubleshooting.
+mail_delivery_agent_bin: "no_mda_needed"
+
+data_dir: "../.data-A"
+mail_log_path: "../.logs/mail_log-A"
+
+# srv-b is our smarthost.
+# We use tls protocol because the smtp one is already well exercised in the
+# package tests.
+smarthost_url: "tls://userB@srv-b:userB@srv-b:2465"
+
diff --git a/test/t-20-smarthost/A/domains/srv-A/aliases b/test/t-20-smarthost/A/domains/srv-A/aliases
new file mode 100644
index 0000000..e69de29
diff --git a/test/t-20-smarthost/B/chasquid.conf b/test/t-20-smarthost/B/chasquid.conf
new file mode 100644
index 0000000..e572536
--- /dev/null
+++ b/test/t-20-smarthost/B/chasquid.conf
@@ -0,0 +1,10 @@
+smtp_address: ":2025"
+submission_address: ":2587"
+submission_over_tls_address: ":2465"
+monitoring_address: ":2099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "%to%"
+
+data_dir: "../.data-B"
+mail_log_path: "../.logs/mail_log-B"
diff --git a/test/t-20-smarthost/B/domains/srv-B/aliases b/test/t-20-smarthost/B/domains/srv-B/aliases
new file mode 100644
index 0000000..e69de29
diff --git a/test/t-20-smarthost/C/chasquid.conf b/test/t-20-smarthost/C/chasquid.conf
new file mode 100644
index 0000000..6ce185f
--- /dev/null
+++ b/test/t-20-smarthost/C/chasquid.conf
@@ -0,0 +1,10 @@
+smtp_address: ":3025"
+submission_address: ":3587"
+submission_over_tls_address: ":3465"
+monitoring_address: ":3099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "%to%"
+
+data_dir: "../.data-C"
+mail_log_path: "../.logs/mail_log-C"
diff --git a/test/t-20-smarthost/C/domains/srv-C/aliases b/test/t-20-smarthost/C/domains/srv-C/aliases
new file mode 100644
index 0000000..e69de29
diff --git a/test/t-20-smarthost/content b/test/t-20-smarthost/content
new file mode 100644
index 0000000..4500898
--- /dev/null
+++ b/test/t-20-smarthost/content
@@ -0,0 +1,9 @@
+From: userA@srv-A
+To: userC@srv-C
+Subject: Los espejos
+
+Yo que sentí el horror de los espejos
+no sólo ante el cristal impenetrable
+donde acaba y empieza, inhabitable,
+un imposible espacio de reflejos
+
diff --git a/test/t-20-smarthost/hosts b/test/t-20-smarthost/hosts
new file mode 100644
index 0000000..1490f91
--- /dev/null
+++ b/test/t-20-smarthost/hosts
@@ -0,0 +1,3 @@
+srv-A localhost
+srv-B localhost
+srv-C localhost
diff --git a/test/t-20-smarthost/msmtprc b/test/t-20-smarthost/msmtprc
new file mode 100644
index 0000000..a46c7eb
--- /dev/null
+++ b/test/t-20-smarthost/msmtprc
@@ -0,0 +1,14 @@
+account default
+
+host srv-A
+port 1587
+
+tls on
+tls_trust_file A/certs/srv-A/fullchain.pem
+
+from userA@srv-A
+
+auth on
+user userA@srv-A
+password userA
+
diff --git a/test/t-20-smarthost/run.sh b/test/t-20-smarthost/run.sh
new file mode 100755
index 0000000..49e1d20
--- /dev/null
+++ b/test/t-20-smarthost/run.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+
+set -e
+. $(dirname ${0})/../util/lib.sh
+
+init
+
+rm -rf .data-A .data-B .data-C .mail .logs
+
+# Build with the DNS override, so we can fake DNS records.
+export GOTAGS="dnsoverride"
+
+# Launch minidns in the background using our configuration.
+minidns_bg --addr=":9053" -zones=zones >> .minidns.log 2>&1
+
+# 3 servers:
+# A - listens on :1025, hosts srv-A
+# B - listens on :2015, hosts srv-B
+# C - listens on :3015, hosts srv-C
+#
+# B and C are normal servers.
+# A will use B as a smarthost.
+#
+# We will send an email from A to C, and expect it to go through B.
+
+mkdir -p .certs
+for i in A B C; do
+		CONFDIR=${i} generate_certs_for srv-${i}
+		CONFDIR=${i} add_user user${i}@srv-${i} user${i}
+		mkdir -p .logs-${i}
+		cp ${i}/certs/srv-${i}/fullchain.pem .certs/cert-${i}.pem
+done
+
+# Make the servers trust each other.
+export SSL_CERT_DIR="$PWD/.certs/"
+
+chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \
+	--testing__dns_addr=127.0.0.1:9053 &
+chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=3025 &
+chasquid -v=2 --logfile=.logs-C/chasquid.log --config_dir=C \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=2025 &
+
+wait_until_ready 1025
+wait_until_ready 2025
+wait_until_ready 3025
+
+# Use A to send to C, and wait for delivery.
+run_msmtp userC@srv-c < content
+wait_for_file .mail/userc@srv-c
+mail_diff content .mail/userc@srv-c
+
+# Check that it went through B.
+if ! grep -q "from=userA@srv-a to=userC@srv-c sent" .logs/mail_log-B; then
+	fail "can't find record of delivery on the smarthost B"
+fi
+
+success
diff --git a/test/t-20-smarthost/zones b/test/t-20-smarthost/zones
new file mode 100644
index 0000000..d144cc9
--- /dev/null
+++ b/test/t-20-smarthost/zones
@@ -0,0 +1,6 @@
+srv-a  A     127.0.0.1
+srv-a  AAAA  ::1
+srv-b  A     127.0.0.1
+srv-b  AAAA  ::1
+srv-c  A     127.0.0.1
+srv-c  AAAA  ::1