git » chasquid » commit 9999a69

aliases: Implement "via" aliases

author Alberto Bertogli
2025-04-06 11:35:51 UTC
committer Alberto Bertogli
2025-04-12 22:23:21 UTC
parent 1cf24ba94a8296d207e05daf12992176bb2c8964

aliases: Implement "via" aliases

This patch implements "via" aliases, which let us explicitly select a
server to use for delivery.

This feature is useful in different scenarios, such as a secondary MX
server that forwards all incoming email to a primary.

For now, it is experimental and the syntax and semantics are subject to
change.

docs/aliases.md +32 -0
internal/aliases/aliases.go +73 -15
internal/aliases/aliases_test.go +79 -6
internal/courier/courier.go +5 -0
internal/courier/mda.go +9 -0
internal/courier/mda_test.go +12 -0
internal/courier/smtp.go +37 -1
internal/queue/queue.go +40 -14
internal/queue/queue.pb.go +31 -18
internal/queue/queue.proto +4 -0
internal/queue/queue_test.go +36 -12
internal/smtpsrv/server_test.go +1 -1
internal/testlib/testlib.go +16 -1
test/t-22-forward_via/.gitignore +3 -0
test/t-22-forward_via/content +7 -0
test/t-22-forward_via/expected-chain-1 +54 -0
test/t-22-forward_via/expected-external-user333@kiwi +27 -0
test/t-22-forward_via/expected-primary-user111@dodo +27 -0
test/t-22-forward_via/external/chasquid.conf +10 -0
test/t-22-forward_via/external/domains/kiwi/aliases +2 -0
test/t-22-forward_via/primary/chasquid.conf +10 -0
test/t-22-forward_via/primary/domains/dodo/aliases +5 -0
test/t-22-forward_via/run.sh +92 -0
test/t-22-forward_via/secondary/chasquid.conf +10 -0
test/t-22-forward_via/secondary/domains/dodo/aliases +8 -0
test/t-22-forward_via/smtpc-secondary.conf +4 -0
test/t-22-forward_via/zones +15 -0
test/util/mail_diff +21 -5

diff --git a/docs/aliases.md b/docs/aliases.md
index 8af704a..0a42c46 100644
--- a/docs/aliases.md
+++ b/docs/aliases.md
@@ -86,6 +86,38 @@ pepe: jose
 
     This is experimental as of chasquid 1.16.0, and subject to change.
 
+### "Via" aliases (experimental)
+
+!!! warning
+
+    This feature is experimental as of chasquid 1.16.0, and subject to change.
+
+A "via" alias is like an email alias, but it explicitly specifies which
+server(s) to use when delivering that email. The servers are used to attempt
+delivery in the given order.
+
+This can be useful in scenarios such as secondary MX servers that forward all
+email to the primary server, or send-only servers.
+
+The syntax is `user: address via server1[/server2/...]`.
+
+Examples:
+
+```
+# Emails sent to pepe@domain will be forwarded to jose@domain using
+# mail.example.com (instead of the MX records of the domain).
+pepe: jose via mail1.example.com
+
+# Same as above, but with multiple servers. They will be tried in order.
+flowers: lilly@pond via mail1.pond/mail2.pond
+
+# Forward all email (that does not match other users or aliases) using
+# mail1.example.com.
+# This is a typical setup for a secondary MX server that forwards email to
+# the primary.
+*: * via mail1.example.com
+```
+
 
 ### Overrides
 
diff --git a/internal/aliases/aliases.go b/internal/aliases/aliases.go
index 505e2bb..1d5213d 100644
--- a/internal/aliases/aliases.go
+++ b/internal/aliases/aliases.go
@@ -83,6 +83,7 @@ var (
 // anyway.
 type Recipient struct {
 	Addr string
+	Via  []string // Used when Type == FORWARD.
 	Type RType
 }
 
@@ -91,8 +92,9 @@ type RType string
 
 // Valid recipient types.
 const (
-	EMAIL RType = "(email)"
-	PIPE  RType = "(pipe)"
+	EMAIL   RType = "(email)"
+	PIPE    RType = "(pipe)"
+	FORWARD RType = "(forward)"
 )
 
 var (
@@ -215,7 +217,7 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
 	user, domain := envelope.Split(addr)
 	if _, ok := v.domains[domain]; !ok {
 		tr.Debugf("%d| non-local domain, returning %q", rcount, addr)
-		return []Recipient{{addr, EMAIL}}, nil
+		return []Recipient{{addr, nil, EMAIL}}, nil
 	}
 
 	// First, see if there's an exact match in the database.
@@ -250,7 +252,7 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
 		}
 		if ok {
 			tr.Debugf("%d| user exists, returning %q", rcount, addr)
-			return []Recipient{{addr, EMAIL}}, nil
+			return []Recipient{{addr, nil, EMAIL}}, nil
 		}
 
 		catchAll, err := v.lookup("*@"+domain, tr)
@@ -274,14 +276,14 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
 			// clearer failure, reusing the existing codepaths and simplifying
 			// the logic.
 			tr.Debugf("%d| no catch-all, returning %q", rcount, addr)
-			return []Recipient{{addr, EMAIL}}, nil
+			return []Recipient{{addr, nil, EMAIL}}, nil
 		}
 	}
 
 	ret := []Recipient{}
 	for _, r := range rcpts {
-		// Only recurse for email recipients.
-		if r.Type != EMAIL {
+		// PIPE recipients get added as-is. No modification, and no recursion.
+		if r.Type == PIPE {
 			ret = append(ret, r)
 			continue
 		}
@@ -296,6 +298,14 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
 			r.Addr = newAddr
 		}
 
+		// Don't recurse FORWARD recipients, since we explicitly want them to
+		// be sent through the given servers.
+		// Note we do this here and not above so we support the right-side *.
+		if r.Type == FORWARD {
+			ret = append(ret, r)
+			continue
+		}
+
 		ar, err := v.resolve(rcount+1, r.Addr, tr)
 		if err != nil {
 			tr.Debugf("%d| resolve(%q) returned error: %v", rcount, r.Addr, err)
@@ -384,8 +394,8 @@ func (v *Resolver) AddAliasesFile(domain, path string) (int, error) {
 
 // AddAliasForTesting adds an alias to the resolver, for testing purposes.
 // Not for use in production code.
-func (v *Resolver) AddAliasForTesting(addr, rcpt string, rType RType) {
-	v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, rType})
+func (v *Resolver) AddAliasForTesting(addr, rcpt string, via []string, rType RType) {
+	v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, via, rType})
 }
 
 // Reload aliases files for all known domains.
@@ -473,6 +483,7 @@ func (v *Resolver) parseReader(domain string, r io.Reader) (map[string][]Recipie
 }
 
 func parseRHS(rawalias, domain string) ([]Recipient, error) {
+	var err error
 	if len(rawalias) == 0 {
 		// Explicitly allow empty rawalias strings at this point: the file
 		// parsing will prevent this at the upper level, and when we parse the
@@ -485,7 +496,7 @@ func parseRHS(rawalias, domain string) ([]Recipient, error) {
 			// A pipe alias without a command is invalid.
 			return nil, fmt.Errorf("the pipe alias is missing a command")
 		}
-		return []Recipient{{cmd, PIPE}}, nil
+		return []Recipient{{cmd, nil, PIPE}}, nil
 	}
 
 	rs := []Recipient{}
@@ -497,20 +508,67 @@ func parseRHS(rawalias, domain string) ([]Recipient, error) {
 			continue
 		}
 
+		r := Recipient{
+			Addr: a,
+			Via:  nil,
+			Type: EMAIL,
+		}
+		if strings.Contains(a, " via ") {
+			// It's a FORWARD, so extract the Via part and continue.
+			r.Type = FORWARD
+			r.Addr, r.Via, err = parseForward(a)
+			if err != nil {
+				return nil, err
+			}
+		}
+
 		// Addresses with no domain get the current one added, so it's
-		// easier to share alias files.
-		if !strings.Contains(a, "@") {
-			a = a + "@" + domain
+		// easier to share alias files. Note we also do this for FORWARD
+		// aliases.
+		if !strings.Contains(r.Addr, "@") {
+			r.Addr = r.Addr + "@" + domain
 		}
-		a, err := normalize.Addr(a)
+
+		r.Addr, err = normalize.Addr(r.Addr)
 		if err != nil {
 			return nil, fmt.Errorf("normalizing address %q: %w", a, err)
 		}
-		rs = append(rs, Recipient{a, EMAIL})
+
+		rs = append(rs, r)
 	}
 	return rs, nil
 }
 
+// Parse a raw FORWARD alias, returning the address and the via part.
+// Expected format is "address via server1/server2/server3".
+func parseForward(rawalias string) (string, []string, error) {
+	// Split the alias into the address and the via part.
+	addr, viaS, found := strings.Cut(rawalias, " via ")
+	if !found {
+		return "", nil, fmt.Errorf("via separator not found")
+	}
+
+	// No need to normalize the address, the caller will do it.
+	// We only trim it, and enforce that there is one.
+	addr = strings.TrimSpace(addr)
+	if addr == "" {
+		return "", nil, fmt.Errorf("forwarding alias is missing the address")
+	}
+
+	// The via part is a list of servers, separated by "/". Split it up.
+	// For now we don't validate the servers, but we may in the future.
+	via := []string{}
+	for _, v := range strings.Split(viaS, "/") {
+		server := strings.TrimSpace(v)
+		if server == "" {
+			return "", nil, fmt.Errorf("empty server in via list")
+		}
+		via = append(via, server)
+	}
+
+	return addr, via, nil
+}
+
 // removeAllAfter removes everything from s that comes after the separators,
 // including them.
 func removeAllAfter(s, seps string) string {
diff --git a/internal/aliases/aliases_test.go b/internal/aliases/aliases_test.go
index f8cf121..a3b0158 100644
--- a/internal/aliases/aliases_test.go
+++ b/internal/aliases/aliases_test.go
@@ -86,11 +86,15 @@ func usersWithXErrorYDontExist(tr *trace.Trace, user, domain string) (bool, erro
 }
 
 func email(addr string) Recipient {
-	return Recipient{addr, EMAIL}
+	return Recipient{addr, nil, EMAIL}
 }
 
 func pipe(addr string) Recipient {
-	return Recipient{addr, PIPE}
+	return Recipient{addr, nil, PIPE}
+}
+
+func forward(addr string, via []string) Recipient {
+	return Recipient{addr, via, FORWARD}
 }
 
 func TestBasic(t *testing.T) {
@@ -101,17 +105,20 @@ func TestBasic(t *testing.T) {
 		"a@localA":   {email("c@d"), email("e@localB")},
 		"e@localB":   {pipe("cmd")},
 		"cmd@localA": {email("x@y")},
+		"x@localA":   {forward("z@localA", []string{"serverX"})},
 	}
 
 	cases := Cases{
 		{"a@localA", []Recipient{email("c@d"), pipe("cmd")}, nil},
 		{"e@localB", []Recipient{pipe("cmd")}, nil},
 		{"x@y", []Recipient{email("x@y")}, nil},
+		{"x@localA", []Recipient{
+			forward("z@localA", []string{"serverX"})}, nil},
 	}
 	cases.check(t, resolver)
 
-	mustExist(t, resolver, "a@localA", "e@localB", "cmd@localA")
-	mustNotExist(t, resolver, "x@y")
+	mustExist(t, resolver, "a@localA", "e@localB", "cmd@localA", "x@localA")
+	mustNotExist(t, resolver, "x@y", "z@localA")
 }
 
 func TestCatchAll(t *testing.T) {
@@ -157,6 +164,7 @@ func TestRightSideAsterisk(t *testing.T) {
 	resolver.AddDomain("dom3")
 	resolver.AddDomain("dom4")
 	resolver.AddDomain("dom5")
+	resolver.AddDomain("dom6")
 	resolver.aliases = map[string][]Recipient{
 		"a@dom1": {email("aaa@remote")},
 
@@ -183,6 +191,10 @@ func TestRightSideAsterisk(t *testing.T) {
 		// This checks which one is used as the "original" user.
 		"a@dom5": {email("b@dom5")},
 		"*@dom5": {email("*@remote")},
+
+		// A forward on the right side.
+		// It forwards to a local domain also check that there's no recursion.
+		"*@dom6": {forward("*@dom1", []string{"server"})},
 	}
 
 	cases := Cases{
@@ -218,6 +230,10 @@ func TestRightSideAsterisk(t *testing.T) {
 		{"a@dom5", []Recipient{email("b@remote")}, nil},
 		{"b@dom5", []Recipient{email("b@remote")}, nil},
 		{"c@dom5", []Recipient{email("c@remote")}, nil},
+
+		// Forwarding case.
+		{"a@dom6", []Recipient{forward("a@dom1", []string{"server"})}, nil},
+		{"b@dom6", []Recipient{forward("b@dom1", []string{"server"})}, nil},
 	}
 	cases.check(t, resolver)
 }
@@ -527,6 +543,11 @@ func TestAddFile(t *testing.T) {
 
 		{"a: c@d, e@f, g\n",
 			[]Recipient{email("c@d"), email("e@f"), email("g@dom")}},
+
+		{"a: b@c, b via sA/sB, d\n", []Recipient{
+			email("b@c"),
+			forward("b@dom", []string{"sA", "sB"}),
+			email("d@dom")}},
 	}
 
 	tr := trace.New("test", "TestAddFile")
@@ -564,6 +585,7 @@ func TestAddFile(t *testing.T) {
 		{"a@dom: b@c \n", "left-side: cannot contain @"},
 		{"a", "line 1: missing ':' in line"},
 		{"a: x y z\n", "disallowed rune encountered"},
+		{"a: f via sA//sB\n", "empty server in via list"},
 	}
 
 	for _, c := range errcases {
@@ -607,6 +629,9 @@ ppp1: p.q+r
 ppp2: p.q
 ppp3: ppp2
 
+# Test some forwarding cases.
+f1: f2 via server1, f3 via server2/server3, c
+
 # Finally one to make the file NOT end in \n:
 y: z`
 
@@ -622,8 +647,8 @@ func TestRichFile(t *testing.T) {
 		t.Fatalf("failed to add file: %v", err)
 	}
 
-	if n != 11 {
-		t.Fatalf("expected 11 aliases, got %d", n)
+	if n != 12 {
+		t.Fatalf("expected 12 aliases, got %d", n)
 	}
 
 	cases := Cases{
@@ -647,6 +672,12 @@ func TestRichFile(t *testing.T) {
 		{"ppp2@dom", []Recipient{email("pb@dom")}, nil},
 		{"ppp3@dom", []Recipient{email("pb@dom")}, nil},
 
+		{"f1@dom", []Recipient{
+			forward("f2@dom", []string{"server1"}),
+			forward("f3@dom", []string{"server2", "server3"}),
+			email("d@e"), email("f@dom"),
+		}, nil},
+
 		{"y@dom", []Recipient{email("z@dom")}, nil},
 	}
 	cases.check(t, resolver)
@@ -779,6 +810,48 @@ func TestHook(t *testing.T) {
 	}
 }
 
+func TestParseForward(t *testing.T) {
+	cases := []struct {
+		raw  string
+		addr string
+		via  []string
+		err  string
+	}{
+		{"", "", nil, "via separator not found"},
+		{"via", "", nil, "via separator not found"},
+		{"via ", "", nil, "via separator not found"},
+		{" via ", "", nil, "forwarding alias is missing the address"},
+		{" via S1", "", nil, "forwarding alias is missing the address"},
+		{"a via ", "", nil, "empty server in via list"},
+		{"a via S1", "a", []string{"S1"}, ""},
+		{"a via S1/S2", "a", []string{"S1", "S2"}, ""},
+		{"a via  S1 / S2 ", "a", []string{"S1", "S2"}, ""},
+		{"a via S1/S2/", "", nil, "empty server in via list"},
+		{"a via S1/S2/ ", "", nil, "empty server in via list"},
+		{"a via S1//S2", "", nil, "empty server in via list"},
+	}
+	for _, c := range cases {
+		addr, via, err := parseForward(c.raw)
+
+		if err != nil && !strings.Contains(err.Error(), c.err) {
+			t.Errorf("case %q: got error %v, expected to contain %q",
+				c.raw, err, c.err)
+			continue
+		} else if err == nil && c.err != "" {
+			t.Errorf("case %q: got nil error, expected %q", c.raw, c.err)
+			t.Logf("  got addr: %q, via: %q", addr, via)
+			continue
+		}
+
+		if addr != c.addr {
+			t.Errorf("case %q: got addr %q, expected %q", c.raw, addr, c.addr)
+		}
+		if !reflect.DeepEqual(via, c.via) {
+			t.Errorf("case %q: got via %q, expected %q", c.raw, via, c.via)
+		}
+	}
+}
+
 // Fuzz testing for the parser.
 func FuzzReader(f *testing.F) {
 	resolver := NewResolver(allUsersExist)
diff --git a/internal/courier/courier.go b/internal/courier/courier.go
index f9a9a24..52d9d8a 100644
--- a/internal/courier/courier.go
+++ b/internal/courier/courier.go
@@ -8,4 +8,9 @@ type Courier interface {
 	// Deliver mail to a recipient. Return the error (if any), and whether it
 	// is permanent (true) or transient (false).
 	Deliver(from string, to string, data []byte) (error, bool)
+
+	// Forward mail using the given servers.
+	// Return the error (if any), and whether it is permanent (true) or
+	// transient (false).
+	Forward(from string, to string, data []byte, servers []string) (error, bool)
 }
diff --git a/internal/courier/mda.go b/internal/courier/mda.go
index 269f7fa..ea7ce6e 100644
--- a/internal/courier/mda.go
+++ b/internal/courier/mda.go
@@ -3,6 +3,7 @@ package courier
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"os/exec"
 	"strings"
@@ -108,3 +109,11 @@ func sanitizeForMDA(s string) string {
 	}
 	return strings.Map(valid, s)
 }
+
+var errForwardNotSupported = errors.New(
+	"forwarding not supported by the MDA courier")
+
+// Forward is not supported by the MDA courier.
+func (p *MDA) Forward(from string, to string, data []byte, servers []string) (error, bool) {
+	return errForwardNotSupported, true
+}
diff --git a/internal/courier/mda_test.go b/internal/courier/mda_test.go
index 6fdb124..16f290e 100644
--- a/internal/courier/mda_test.go
+++ b/internal/courier/mda_test.go
@@ -123,3 +123,15 @@ func TestSanitize(t *testing.T) {
 		}
 	}
 }
+
+func TestForward(t *testing.T) {
+	p := MDA{"thisdoesnotexist", nil, 1 * time.Minute}
+	err, permanent := p.Forward(
+		"from", "to", []byte("data"), []string{"server"})
+	if err != errForwardNotSupported {
+		t.Errorf("unexpected error: %v", err)
+	}
+	if !permanent {
+		t.Errorf("expected permanent, got transient")
+	}
+}
diff --git a/internal/courier/smtp.go b/internal/courier/smtp.go
index 0b7cc1b..6c68576 100644
--- a/internal/courier/smtp.go
+++ b/internal/courier/smtp.go
@@ -63,7 +63,7 @@ func (s *SMTP) Deliver(from string, to string, data []byte) (error, bool) {
 		to:       to,
 		toDomain: envelope.DomainOf(to),
 		data:     data,
-		tr:       trace.New("Courier.SMTP", to),
+		tr:       trace.New("Courier.SMTP.Deliver", to),
 	}
 	defer a.tr.Finish()
 	a.tr.Debugf("%s  ->  %s", from, to)
@@ -105,6 +105,42 @@ func (s *SMTP) Deliver(from string, to string, data []byte) (error, bool) {
 	return a.tr.Errorf("all MXs returned transient failures (last: %v)", err), false
 }
 
+// Forward an email. On failures, returns an error, and whether or not it is
+// permanent.
+func (s *SMTP) Forward(from string, to string, data []byte, servers []string) (error, bool) {
+	a := &attempt{
+		courier:  s,
+		from:     from,
+		to:       to,
+		toDomain: envelope.DomainOf(to),
+		data:     data,
+		tr:       trace.New("Courier.SMTP.Forward", to),
+	}
+	defer a.tr.Finish()
+	a.tr.Debugf("%s  ->  %s", from, to)
+
+	// smtp.Client.Mail will add the <> for us when the address is empty.
+	if a.from == "<>" {
+		a.from = ""
+	}
+
+	var err error
+	for _, server := range servers {
+		var permanent bool
+		err, permanent = a.deliver(server)
+		if err == nil {
+			return nil, false
+		}
+		if permanent {
+			return err, true
+		}
+		a.tr.Errorf("%q returned transient error: %v", server, err)
+	}
+
+	// We exhausted all servers, try again later.
+	return a.tr.Errorf("all servers returned transient failures (last: %v)", err), false
+}
+
 type attempt struct {
 	courier *SMTP
 
diff --git a/internal/queue/queue.go b/internal/queue/queue.go
index 71ff42f..1b8295a 100644
--- a/internal/queue/queue.go
+++ b/internal/queue/queue.go
@@ -191,6 +191,9 @@ func (q *Queue) Put(tr *trace.Trace, from string, to []string, data []byte) (str
 				r.Type = Recipient_EMAIL
 			case aliases.PIPE:
 				r.Type = Recipient_PIPE
+			case aliases.FORWARD:
+				r.Type = Recipient_FORWARD
+				r.Via = aliasRcpt.Via
 			default:
 				log.Errorf("unknown alias type %v when resolving %q",
 					aliasRcpt.Type, t)
@@ -387,6 +390,21 @@ func (item *Item) deliver(q *Queue, rcpt *Recipient) (err error, permanent bool)
 		return cmd.Run(), true
 	}
 
+	// Recipient type is FORWARD: we always use the remote courier, and pass
+	// the list of servers that was given to us.
+	if rcpt.Type == Recipient_FORWARD {
+		deliverAttempts.Add("forward", 1)
+
+		// When forwarding with an explicit list of servers, we use SRS if
+		// we're sending from a non-local domain (regardless of the
+		// destination).
+		from := item.From
+		if !envelope.DomainIn(item.From, q.localDomains) {
+			from = rewriteSender(item.From, rcpt.OriginalAddress)
+		}
+		return q.remoteC.Forward(from, rcpt.Address, item.Data, rcpt.Via)
+	}
+
 	// Recipient type is EMAIL.
 	if envelope.DomainIn(rcpt.Address, q.localDomains) {
 		deliverAttempts.Add("email:local", 1)
@@ -396,24 +414,32 @@ func (item *Item) deliver(q *Queue, rcpt *Recipient) (err error, permanent bool)
 	deliverAttempts.Add("email:remote", 1)
 	from := item.From
 	if !envelope.DomainIn(item.From, q.localDomains) {
-		// We're sending from a non-local to a non-local. This should
-		// happen only when there's an alias to forward email to a
-		// non-local domain.  In this case, using the original From is
-		// problematic, as we may not be an authorized sender for this.
-		// Some MTAs (like Exim) will do it anyway, others (like
-		// gmail) will construct a special address based on the
-		// original address.  We go with the latter.
-		// Note this assumes "+" is an alias suffix separator.
-		// We use the IDNA version of the domain if possible, because
-		// we can't know if the other side will support SMTPUTF8.
-		from = fmt.Sprintf("%s+fwd_from=%s@%s",
-			envelope.UserOf(rcpt.OriginalAddress),
-			strings.Replace(from, "@", "=", -1),
-			mustIDNAToASCII(envelope.DomainOf(rcpt.OriginalAddress)))
+		// We're sending from a non-local to a non-local, need to do SRS.
+		from = rewriteSender(item.From, rcpt.OriginalAddress)
 	}
 	return q.remoteC.Deliver(from, rcpt.Address, item.Data)
 }
 
+func rewriteSender(from, originalAddr string) string {
+	// Apply a send-only Sender Rewriting Scheme (SRS).
+	// This is used when we are sending from a (potentially) non-local domain,
+	// to a non-local domain.
+	// This should happen only when there's an alias to forward email to a
+	// non-local domain (either a normal "email" alias with a remote
+	// destination, or a "forward" alias with a list of servers).
+	// In this case, using the original From is problematic, as we may not be
+	// an authorized sender for this.
+	// To do this, we use a sender rewriting scheme, similar to what other
+	// MTAs do (e.g. gmail or postfix).
+	// Note this assumes "+" is an alias suffix separator.
+	// We use the IDNA version of the domain if possible, because
+	// we can't know if the other side will support SMTPUTF8.
+	return fmt.Sprintf("%s+fwd_from=%s@%s",
+		envelope.UserOf(originalAddr),
+		strings.Replace(from, "@", "=", -1),
+		mustIDNAToASCII(envelope.DomainOf(originalAddr)))
+}
+
 // countRcpt counts how many recipients are in the given status.
 func (item *Item) countRcpt(statuses ...Recipient_Status) int {
 	c := 0
diff --git a/internal/queue/queue.pb.go b/internal/queue/queue.pb.go
index 779d96e..cefd7ca 100644
--- a/internal/queue/queue.pb.go
+++ b/internal/queue/queue.pb.go
@@ -23,8 +23,9 @@ const (
 type Recipient_Type int32
 
 const (
-	Recipient_EMAIL Recipient_Type = 0
-	Recipient_PIPE  Recipient_Type = 1
+	Recipient_EMAIL   Recipient_Type = 0
+	Recipient_PIPE    Recipient_Type = 1
+	Recipient_FORWARD Recipient_Type = 2
 )
 
 // Enum value maps for Recipient_Type.
@@ -32,10 +33,12 @@ var (
 	Recipient_Type_name = map[int32]string{
 		0: "EMAIL",
 		1: "PIPE",
+		2: "FORWARD",
 	}
 	Recipient_Type_value = map[string]int32{
-		"EMAIL": 0,
-		"PIPE":  1,
+		"EMAIL":   0,
+		"PIPE":    1,
+		"FORWARD": 2,
 	}
 )
 
@@ -221,6 +224,8 @@ type Recipient struct {
 	// This is before expanding aliases and only used in very particular
 	// cases.
 	OriginalAddress string `protobuf:"bytes,5,opt,name=original_address,json=originalAddress,proto3" json:"original_address,omitempty"`
+	// The list of servers to use, for recipients of type == FORWARD.
+	Via []string `protobuf:"bytes,6,rep,name=via,proto3" json:"via,omitempty"`
 }
 
 func (x *Recipient) Reset() {
@@ -290,6 +295,13 @@ func (x *Recipient) GetOriginalAddress() string {
 	return ""
 }
 
+func (x *Recipient) GetVia() []string {
+	if x != nil {
+		return x.Via
+	}
+	return nil
+}
+
 // Timestamp representation, for convenience.
 // We used to use the well-known type, but the dependency makes packaging much
 // more convoluted and adds very little value, so we now just include it here.
@@ -365,7 +377,7 @@ var file_queue_proto_rawDesc = []byte{
 	0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x74, 0x73, 0x18,
 	0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x2e, 0x54, 0x69,
 	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
-	0x41, 0x74, 0x54, 0x73, 0x22, 0xa8, 0x02, 0x0a, 0x09, 0x52, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65,
+	0x41, 0x74, 0x54, 0x73, 0x22, 0xc7, 0x02, 0x0a, 0x09, 0x52, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65,
 	0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
 	0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x04,
 	0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x71, 0x75, 0x65,
@@ -379,19 +391,20 @@ var file_queue_proto_rawDesc = []byte{
 	0x75, 0x72, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x6f, 0x72,
 	0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x05,
 	0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x41, 0x64,
-	0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0x1b, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a,
-	0x05, 0x45, 0x4d, 0x41, 0x49, 0x4c, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x50, 0x49, 0x50, 0x45,
-	0x10, 0x01, 0x22, 0x2b, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07,
-	0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x45, 0x4e,
-	0x54, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x02, 0x22,
-	0x3b, 0x0a, 0x09, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x18, 0x0a, 0x07,
-	0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x73,
-	0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6e, 0x6f, 0x73, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6e, 0x61, 0x6e, 0x6f, 0x73, 0x42, 0x2b, 0x5a, 0x29,
-	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, 0x71, 0x75, 0x65, 0x75, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
-	0x33,
+	0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x76, 0x69, 0x61, 0x18, 0x06, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x03, 0x76, 0x69, 0x61, 0x22, 0x28, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12,
+	0x09, 0x0a, 0x05, 0x45, 0x4d, 0x41, 0x49, 0x4c, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x50, 0x49,
+	0x50, 0x45, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, 0x44, 0x10,
+	0x02, 0x22, 0x2b, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x50,
+	0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x45, 0x4e, 0x54,
+	0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x02, 0x22, 0x3b,
+	0x0a, 0x09, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x73,
+	0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x73, 0x65,
+	0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6e, 0x6f, 0x73, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6e, 0x61, 0x6e, 0x6f, 0x73, 0x42, 0x2b, 0x5a, 0x29, 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, 0x71, 0x75, 0x65, 0x75, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
diff --git a/internal/queue/queue.proto b/internal/queue/queue.proto
index ce3adab..073edca 100644
--- a/internal/queue/queue.proto
+++ b/internal/queue/queue.proto
@@ -27,6 +27,7 @@ message Recipient {
 	enum Type {
 		EMAIL = 0;
 		PIPE = 1;
+                FORWARD = 2;
 	}
 	Type type = 2;
 
@@ -43,6 +44,9 @@ message Recipient {
 	// This is before expanding aliases and only used in very particular
 	// cases.
 	string original_address = 5;
+
+        // The list of servers to use, for recipients of type == FORWARD.
+        repeated string via = 6;
 }
 
 // Timestamp representation, for convenience.
diff --git a/internal/queue/queue_test.go b/internal/queue/queue_test.go
index eecd164..9b86656 100644
--- a/internal/queue/queue_test.go
+++ b/internal/queue/queue_test.go
@@ -127,28 +127,52 @@ func TestAliases(t *testing.T) {
 	defer tr.Finish()
 
 	q.aliases.AddDomain("loco")
-	q.aliases.AddAliasForTesting("ab@loco", "pq@loco", aliases.EMAIL)
-	q.aliases.AddAliasForTesting("ab@loco", "rs@loco", aliases.EMAIL)
-	q.aliases.AddAliasForTesting("cd@loco", "ata@hualpa", aliases.EMAIL)
+	q.aliases.AddAliasForTesting("ab@loco", "pq@loco", nil, aliases.EMAIL)
+	q.aliases.AddAliasForTesting("ab@loco", "rs@loco", nil, aliases.EMAIL)
+	q.aliases.AddAliasForTesting("cd@loco", "ata@hualpa", nil, aliases.EMAIL)
+	q.aliases.AddAliasForTesting(
+		"fwd@loco", "fwd@loco", []string{"server"}, aliases.FORWARD)
+	q.aliases.AddAliasForTesting(
+		"remote@loco", "remote@rana", []string{"server"}, aliases.FORWARD)
 	// Note the pipe aliases are tested below, as they don't use the couriers
 	// and it can be quite inconvenient to test them in this way.
 
 	localC.Expect(2)
-	remoteC.Expect(1)
-	_, err := q.Put(tr, "from", []string{"ab@loco", "cd@loco"}, []byte("data"))
+	remoteC.Expect(3)
+
+	// One email from a local domain: from@loco -> ab@loco, cd@loco, fwd@loco.
+	_, err := q.Put(tr, "from@loco",
+		[]string{"ab@loco", "cd@loco", "fwd@loco"},
+		[]byte("data"))
+	if err != nil {
+		t.Fatalf("Put: %v", err)
+	}
+
+	// And another from a remote domain: from@rana -> remote@loco
+	_, err = q.Put(tr, "from@rana",
+		[]string{"remote@loco"},
+		[]byte("data"))
 	if err != nil {
 		t.Fatalf("Put: %v", err)
 	}
+
 	localC.Wait()
 	remoteC.Wait()
 
 	cases := []struct {
-		courier    *testlib.TestCourier
-		expectedTo string
+		courier      *testlib.TestCourier
+		expectedFrom string
+		expectedTo   string
 	}{
-		{localC, "pq@loco"},
-		{localC, "rs@loco"},
-		{remoteC, "ata@hualpa"},
+		// From the local domain: from@loco
+		{localC, "from@loco", "pq@loco"},
+		{localC, "from@loco", "rs@loco"},
+		{remoteC, "from@loco", "ata@hualpa"},
+		{remoteC, "from@loco", "fwd@loco"},
+
+		// From the remote domain: from@rana.
+		// Note the SRS in the remoteC.
+		{remoteC, "remote+fwd_from=from=rana@loco", "remote@rana"},
 	}
 	for _, c := range cases {
 		req := c.courier.ReqFor[c.expectedTo]
@@ -157,9 +181,9 @@ func TestAliases(t *testing.T) {
 			continue
 		}
 
-		if req.From != "from" || req.To != c.expectedTo ||
+		if req.From != c.expectedFrom || req.To != c.expectedTo ||
 			!bytes.Equal(req.Data, []byte("data")) {
-			t.Errorf("wrong request for %q: %v", c.expectedTo, req)
+			t.Errorf("wrong request for %q: %v", c.expectedTo, *req)
 		}
 	}
 }
diff --git a/internal/smtpsrv/server_test.go b/internal/smtpsrv/server_test.go
index 348c3b9..9c98828 100644
--- a/internal/smtpsrv/server_test.go
+++ b/internal/smtpsrv/server_test.go
@@ -671,7 +671,7 @@ func realMain(m *testing.M) int {
 		udb := userdb.New("/dev/null")
 		udb.AddUser("testuser", "testpasswd")
 		s.aliasesR.AddAliasForTesting(
-			"to@localhost", "testuser@localhost", aliases.EMAIL)
+			"to@localhost", "testuser@localhost", nil, aliases.EMAIL)
 		s.authr.Register("localhost", auth.WrapNoErrorBackend(udb))
 		s.AddDomain("localhost")
 
diff --git a/internal/testlib/testlib.go b/internal/testlib/testlib.go
index ac864d5..d218300 100644
--- a/internal/testlib/testlib.go
+++ b/internal/testlib/testlib.go
@@ -89,6 +89,7 @@ type deliverRequest struct {
 	From string
 	To   string
 	Data []byte
+	Via  []string
 }
 
 // TestCourier never fails, and always remembers everything.
@@ -102,7 +103,17 @@ type TestCourier struct {
 // Deliver the given mail (saving it in tc.Requests).
 func (tc *TestCourier) Deliver(from string, to string, data []byte) (error, bool) {
 	defer tc.wg.Done()
-	dr := &deliverRequest{from, to, data}
+	dr := &deliverRequest{from, to, data, nil}
+	tc.Lock()
+	tc.Requests = append(tc.Requests, dr)
+	tc.ReqFor[to] = dr
+	tc.Unlock()
+	return nil, false
+}
+
+func (tc *TestCourier) Forward(from string, to string, data []byte, servers []string) (error, bool) {
+	defer tc.wg.Done()
+	dr := &deliverRequest{from, to, data, servers}
 	tc.Lock()
 	tc.Requests = append(tc.Requests, dr)
 	tc.ReqFor[to] = dr
@@ -133,6 +144,10 @@ func (c dumbCourier) Deliver(from string, to string, data []byte) (error, bool)
 	return nil, false
 }
 
+func (c dumbCourier) Forward(from string, to string, data []byte, servers []string) (error, bool) {
+	return nil, false
+}
+
 // DumbCourier always succeeds delivery, and ignores everything.
 var DumbCourier = dumbCourier{}
 
diff --git a/test/t-22-forward_via/.gitignore b/test/t-22-forward_via/.gitignore
new file mode 100644
index 0000000..ef1ae21
--- /dev/null
+++ b/test/t-22-forward_via/.gitignore
@@ -0,0 +1,3 @@
+primary/**/users
+secondary/**/users
+external/**/users
diff --git a/test/t-22-forward_via/content b/test/t-22-forward_via/content
new file mode 100644
index 0000000..e082e47
--- /dev/null
+++ b/test/t-22-forward_via/content
@@ -0,0 +1,7 @@
+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-22-forward_via/expected-chain-1 b/test/t-22-forward_via/expected-chain-1
new file mode 100644
index 0000000..b1f9460
--- /dev/null
+++ b/test/t-22-forward_via/expected-chain-1
@@ -0,0 +1,54 @@
+Authentication-Results: primary
+	;spf=pass (matched mx)
+	;dkim=pass  header.b=????????????  header.d=dodo
+Received-SPF: pass (matched mx)
+Received: from *
+	by primary (chasquid) with ESMTPS
+	tls TLS_*
+	(over SMTP, TLS-1.3, envelope from "chain-1-4+fwd_from=chain-1-3+fwd_from=user222=dodo=kiwi@dodo")
+	; *
+Authentication-Results: secondary
+	;spf=pass (matched mx)
+	;dkim=pass  header.b=????????????  header.d=dodo
+Received-SPF: pass (matched mx)
+Received: from *
+	by secondary (chasquid) with ESMTPS
+	tls TLS_*
+	(over SMTP, TLS-1.3, envelope from "chain-1-3+fwd_from=user222=dodo@kiwi")
+	; *
+Authentication-Results: external
+	;spf=pass (matched mx)
+	;dkim=pass  header.b=????????????  header.d=dodo
+Received-SPF: pass (matched mx)
+Received: from *
+	by external (chasquid) with ESMTPS
+	tls TLS_*
+	(over SMTP, TLS-1.3, envelope from "user222@dodo")
+	; *
+Authentication-Results: primary
+	;spf=pass (matched mx)
+	;dkim=pass  header.b=????????????  header.d=dodo
+Received-SPF: pass (matched mx)
+Received: from *
+	by primary (chasquid) with ESMTPS
+	tls TLS_*
+	(over SMTP, TLS-1.3, envelope from "user222@dodo")
+	; *
+Received: from localhost
+	by secondary (chasquid) with ESMTPSA
+	tls TLS_*
+	(over submission+TLS, TLS-1.3, envelope from "user222@dodo")
+	; *
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+	d=dodo; s=sel-secondary-1; t=*
+	h=subject:from:subject:date:to:cc:message-id;
+	bh=*
+	b=*
+	  *
+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-22-forward_via/expected-external-user333@kiwi b/test/t-22-forward_via/expected-external-user333@kiwi
new file mode 100644
index 0000000..e97187d
--- /dev/null
+++ b/test/t-22-forward_via/expected-external-user333@kiwi
@@ -0,0 +1,27 @@
+Authentication-Results: external
+	;spf=pass (matched mx)
+	;dkim=pass  header.b=????????????  header.d=dodo
+Received-SPF: pass (matched mx)
+Received: from *
+	by external (chasquid) with ESMTPS
+	tls TLS_*
+	(over SMTP, TLS-1.3, envelope from "user222@dodo")
+	; *
+Received: from localhost
+	by secondary (chasquid) with ESMTPSA
+	tls TLS_*
+	(over submission+TLS, TLS-1.3, envelope from "user222@dodo")
+	; *
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+	d=dodo; s=sel-secondary-1; t=*
+	h=subject:from:subject:date:to:cc:message-id;
+	bh=*
+	b=*
+	  *
+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-22-forward_via/expected-primary-user111@dodo b/test/t-22-forward_via/expected-primary-user111@dodo
new file mode 100644
index 0000000..6f43f78
--- /dev/null
+++ b/test/t-22-forward_via/expected-primary-user111@dodo
@@ -0,0 +1,27 @@
+Authentication-Results: primary
+	;spf=pass (matched mx)
+	;dkim=pass  header.b=????????????  header.d=dodo
+Received-SPF: pass (matched mx)
+Received: from *
+	by primary (chasquid) with ESMTPS
+	tls TLS_*
+	(over SMTP, TLS-1.3, envelope from "user222@dodo")
+	; *
+Received: from localhost
+	by secondary (chasquid) with ESMTPSA
+	tls TLS_*
+	(over submission+TLS, TLS-1.3, envelope from "user222@dodo")
+	; *
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+	d=dodo; s=sel-secondary-1; t=*
+	h=subject:from:subject:date:to:cc:message-id;
+	bh=*
+	b=*
+	  *
+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-22-forward_via/external/chasquid.conf b/test/t-22-forward_via/external/chasquid.conf
new file mode 100644
index 0000000..e891189
--- /dev/null
+++ b/test/t-22-forward_via/external/chasquid.conf
@@ -0,0 +1,10 @@
+smtp_address: "127.0.0.20:1025"
+submission_address: ":3587"
+submission_over_tls_address: ":3465"
+monitoring_address: ":3099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "external:%to%"
+
+data_dir: "../.data-external"
+mail_log_path: "../.logs/external-mail_log"
diff --git a/test/t-22-forward_via/external/domains/kiwi/aliases b/test/t-22-forward_via/external/domains/kiwi/aliases
new file mode 100644
index 0000000..1d40706
--- /dev/null
+++ b/test/t-22-forward_via/external/domains/kiwi/aliases
@@ -0,0 +1,2 @@
+# Part 3 of chain-1 (see run.sh for the full structure).
+chain-1-3: chain-1-4@dodo via secondary
diff --git a/test/t-22-forward_via/primary/chasquid.conf b/test/t-22-forward_via/primary/chasquid.conf
new file mode 100644
index 0000000..9d6009d
--- /dev/null
+++ b/test/t-22-forward_via/primary/chasquid.conf
@@ -0,0 +1,10 @@
+smtp_address: "127.0.0.10:1025"
+submission_address: ":1587"
+submission_over_tls_address: ":1465"
+monitoring_address: ":1099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "primary:%to%"
+
+data_dir: "../.data-primary"
+mail_log_path: "../.logs/primary-mail_log"
diff --git a/test/t-22-forward_via/primary/domains/dodo/aliases b/test/t-22-forward_via/primary/domains/dodo/aliases
new file mode 100644
index 0000000..a8ae889
--- /dev/null
+++ b/test/t-22-forward_via/primary/domains/dodo/aliases
@@ -0,0 +1,5 @@
+# Part 2 of chain-1 (see run.sh for the full structure).
+chain-1-2: chain-1-3@kiwi
+
+# Part 5 of chain-1 (see run.sh for the full structure).
+chain-1-5: user111@dodo
diff --git a/test/t-22-forward_via/run.sh b/test/t-22-forward_via/run.sh
new file mode 100755
index 0000000..1d7c22b
--- /dev/null
+++ b/test/t-22-forward_via/run.sh
@@ -0,0 +1,92 @@
+#!/bin/bash
+
+set -e
+. "$(dirname "$0")/../util/lib.sh"
+
+init
+check_hostaliases
+
+rm -rf .data-primary .data-secondary .data-external .mail
+rm -f {primary,secondary,external}/domains/*/dkim:*.pem
+
+# Build with the DNS override, so we can fake DNS records.
+export GOTAGS="dnsoverride"
+
+# Two servers for the same domain "dodo":
+# primary - listens on 127.0.0.10:1025
+# secondary - listens on 127.0.0.11:1025
+#
+# One server for domain "kiwi":
+# external - listens on 127.0.0.20:1025
+
+CONFDIR=primary generate_certs_for primary
+CONFDIR=primary add_user user111@dodo user111
+CONFDIR=primary chasquid-util dkim-keygen --algo=ed25519 \
+	dodo sel-primary-1 > /dev/null
+
+CONFDIR=secondary generate_certs_for secondary
+CONFDIR=secondary add_user user222@dodo user222
+CONFDIR=secondary chasquid-util dkim-keygen --algo=ed25519 \
+	dodo sel-secondary-1 > /dev/null
+
+CONFDIR=external generate_certs_for external
+CONFDIR=external add_user user333@kiwi user333
+CONFDIR=external chasquid-util dkim-keygen --algo=ed25519 \
+	kiwi sel-external-1 > /dev/null
+
+
+# Launch minidns in the background using our configuration.
+# Augment the zones with the dkim ones.
+cp zones .zones
+CONFDIR=primary   chasquid-util dkim-dns dodo | sed 's/"//g' >> .zones
+CONFDIR=secondary chasquid-util dkim-dns dodo | sed 's/"//g' >> .zones
+CONFDIR=external  chasquid-util dkim-dns kiwi | sed 's/"//g' >> .zones
+minidns_bg --addr=":9053" -zones=.zones >> .minidns.log 2>&1
+
+
+mkdir -p .logs
+
+chasquid -v=2 --logfile=.logs/primary-chasquid.log --config_dir=primary \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=1025 &
+chasquid -v=2 --logfile=.logs/secondary-chasquid.log --config_dir=secondary \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=1025 &
+chasquid -v=2 --logfile=.logs/external-chasquid.log --config_dir=external \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=1025 &
+
+wait_until "true < /dev/tcp/127.0.0.10/1025" 2>/dev/null
+wait_until "true < /dev/tcp/127.0.0.11/1025" 2>/dev/null
+wait_until "true < /dev/tcp/127.0.0.20/1025" 2>/dev/null
+wait_until_ready 9053
+
+# Connect to secondary, send an email to user111@dodo (which exists only in
+# the primary).  It should be forwarded to the primary.
+# Note this also verifies SRS is correct (by comparing the Received headers),
+# and that DKIM signatures are generated by secondary, and successfully
+# validated by primary.
+smtpc -c=smtpc-secondary.conf user111@dodo < content
+wait_for_file .mail/primary:user111@dodo
+mail_diff expected-primary-user111@dodo .mail/primary:user111@dodo
+
+# Connect to the secondary, send an email to user333@kiwi (which exists only
+# in external). It should be DKIM signed and delivered to the external server.
+# This is a normal delivery.
+smtpc -c=smtpc-secondary.conf user333@kiwi < content
+wait_for_file .mail/external:user333@kiwi
+mail_diff expected-external-user333@kiwi .mail/external:user333@kiwi
+
+# Connect to the secondary, send an email to chain-1@dodo, which has a long
+# alias chain:
+#   secondary: chain-1-1@dodo  ->  chain-1-2@dodo via primary
+#   primary:   chain-1-2@dodo  ->  chain-1-3@kiwi
+#   external:  chain-1-3@kiwi  ->  chain-1-4@dodo via secondary
+#   secondary: chain-1-4@dodo  ->  chain-1-5@dodo via primary
+#   primary:   chain-1-5@dodo  ->  user111@dodo
+rm .mail/primary:user111@dodo
+smtpc -c=smtpc-secondary.conf chain-1-1@dodo < content
+wait_for_file .mail/primary:user111@dodo
+mail_diff expected-chain-1 .mail/primary:user111@dodo
+
+success
diff --git a/test/t-22-forward_via/secondary/chasquid.conf b/test/t-22-forward_via/secondary/chasquid.conf
new file mode 100644
index 0000000..5a7f95c
--- /dev/null
+++ b/test/t-22-forward_via/secondary/chasquid.conf
@@ -0,0 +1,10 @@
+smtp_address: "127.0.0.11:1025"
+submission_address: ":2587"
+submission_over_tls_address: ":2465"
+monitoring_address: ":2099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "secondary:%to%"
+
+data_dir: "../.data-secondary"
+mail_log_path: "../.logs/secondary-mail_log"
diff --git a/test/t-22-forward_via/secondary/domains/dodo/aliases b/test/t-22-forward_via/secondary/domains/dodo/aliases
new file mode 100644
index 0000000..435293d
--- /dev/null
+++ b/test/t-22-forward_via/secondary/domains/dodo/aliases
@@ -0,0 +1,8 @@
+# Part 1 of chain-1 (see run.sh for the full structure).
+chain-1-1: chain-1-2@dodo via primary
+
+# Part 4 chain-1 (see run.sh for the full structure).
+chain-1-4: chain-1-5@dodo via primary
+
+# Forward all email to the primary server.
+*: * via primary
diff --git a/test/t-22-forward_via/smtpc-secondary.conf b/test/t-22-forward_via/smtpc-secondary.conf
new file mode 100644
index 0000000..5a99708
--- /dev/null
+++ b/test/t-22-forward_via/smtpc-secondary.conf
@@ -0,0 +1,4 @@
+addr localhost:2465
+server_cert secondary/certs/secondary/fullchain.pem
+user user222@dodo
+password user222
diff --git a/test/t-22-forward_via/zones b/test/t-22-forward_via/zones
new file mode 100644
index 0000000..735f6c0
--- /dev/null
+++ b/test/t-22-forward_via/zones
@@ -0,0 +1,15 @@
+primary    A     127.0.0.10
+secondary  A     127.0.0.11
+external   A     127.0.0.20
+
+dodo       MX 10 primary
+dodo       MX 20 secondary
+
+# We need to use mx/8 because the source address will usually be 127.0.0.1,
+# not 127.0.0.10 or 127.0.0.11.
+# TODO: Once we support specifying a sender IP address, we should use that
+# and remove the /8.
+dodo       TXT v=spf1 mx/8 -all
+
+kiwi       MX 10 external
+kiwi       TXT v=spf1 mx/8 -all
diff --git a/test/util/mail_diff b/test/util/mail_diff
index d14801b..f1e57a0 100755
--- a/test/util/mail_diff
+++ b/test/util/mail_diff
@@ -57,11 +57,27 @@ def msg_equals(expected, msg):
         if expected[h] == "*":
             continue
 
-        if not flexible_eq(val, msg[h]):
-            print("Header %r differs:" % h)
-            print("Exp: %r" % val)
-            print("Got: %r" % msg[h])
-            diff = True
+        msg_hdr_vals = msg.get_all(h, [])
+        if len(msg_hdr_vals) == 1:
+            if not flexible_eq(val, msg[h]):
+                print("Header %r differs:" % h)
+                print("Exp: %r" % val)
+                print("Got: %r" % msg[h])
+                diff = True
+        else:
+            # We have multiple values for this header, so we need to check each
+            # one, and only return a diff if none of them match.
+            # Note this will result in a false positive if two headers match
+            # the same expected one, but this is good enough for now.
+            for msg_hdr_val in msg_hdr_vals:
+                if flexible_eq(val, msg_hdr_val):
+                    break
+            else:
+                print("Header %r differs, no matching header found" % h)
+                print("Exp: %r" % val)
+                for i, msg_hdr_val in enumerate(msg_hdr_vals):
+                    print("Got %d: %r" % (i, msg_hdr_val))
+                diff = True
 
     if diff:
         return False