author | Alberto Bertogli
<albertito@blitiri.com.ar> 2025-04-06 11:35:51 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2025-04-12 22:23:21 UTC |
parent | 1cf24ba94a8296d207e05daf12992176bb2c8964 |
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