1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-17 14:37:02 +00:00

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.
This commit is contained in:
Alberto Bertogli
2025-04-06 12:35:51 +01:00
parent 1cf24ba94a
commit 9999a69086
28 changed files with 671 additions and 74 deletions

View File

@@ -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

View File

@@ -83,6 +83,7 @@ var (
// anyway.
type Recipient struct {
Addr string
Via []string // Used when Type == FORWARD.
Type RType
}
@@ -93,6 +94,7 @@ type RType string
const (
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
}
// Addresses with no domain get the current one added, so it's
// easier to share alias files.
if !strings.Contains(a, "@") {
a = a + "@" + domain
r := Recipient{
Addr: a,
Via: nil,
Type: EMAIL,
}
a, err := normalize.Addr(a)
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. Note we also do this for FORWARD
// aliases.
if !strings.Contains(r.Addr, "@") {
r.Addr = r.Addr + "@" + domain
}
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 {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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,22 +414,30 @@ 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.
// 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.
from = fmt.Sprintf("%s+fwd_from=%s@%s",
envelope.UserOf(rcpt.OriginalAddress),
return fmt.Sprintf("%s+fwd_from=%s@%s",
envelope.UserOf(originalAddr),
strings.Replace(from, "@", "=", -1),
mustIDNAToASCII(envelope.DomainOf(rcpt.OriginalAddress)))
}
return q.remoteC.Deliver(from, rcpt.Address, item.Data)
mustIDNAToASCII(envelope.DomainOf(originalAddr)))
}
// countRcpt counts how many recipients are in the given status.

View File

@@ -25,6 +25,7 @@ type Recipient_Type int32
const (
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,
"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 (

View File

@@ -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.

View File

@@ -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
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)
}
}
}

View File

@@ -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")

View File

@@ -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{}

3
test/t-22-forward_via/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
primary/**/users
secondary/**/users
external/**/users

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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

92
test/t-22-forward_via/run.sh Executable file
View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,4 @@
addr localhost:2465
server_cert secondary/certs/secondary/fullchain.pem
user user222@dodo
password user222

View File

@@ -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

View File

@@ -57,11 +57,27 @@ def msg_equals(expected, msg):
if expected[h] == "*":
continue
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