diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 830811f..06c90e2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -31,3 +31,9 @@ jobs: uses: shogo82148/actions-goveralls@v1 with: path-to-profile: profile.cov + + - name: Install Postfix + run: sudo -n -- apt-get update -q && sudo -n -- apt-get install -y postfix sasl2-bin libsasl2-2 libsasl2-modules ssl-cert cpio courier-authlib + + - name: Integration Test + run: cd integration && SKIP_POSTFIX_AUTH=1 go run github.com/d--j/go-milter/integration/runner ./tests diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..76e862a --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +GO_MILTER_DIR := $(shell go list -f '{{.Dir}}' github.com/d--j/go-milter) + +integration: + docker build -q --progress=plain -t go-milter-integration "$(GO_MILTER_DIR)/integration/docker" && \ + docker run --rm -w /usr/src/root/integration -v $(PWD):/usr/src/root go-milter-integration \ + go run github.com/d--j/go-milter/integration/runner -filter '.*' ./tests + +.PHONY: integration diff --git a/README.md b/README.md index d9fbf26..826ac41 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A Go library to write mail filters. * milter can skip e.g. body chunks when it does not need all chunks * milter can send progress notifications when response can take some time * milter can automatically instruct the MTA which macros it needs. +* Automatic [integration tests](integration/README.md) that test the compatibility with Postfix and Sendmail. ## Installation diff --git a/client.go b/client.go index c8191dc..b757f1e 100644 --- a/client.go +++ b/client.go @@ -688,9 +688,24 @@ func (s *ClientSession) DataStart() (*Action, error) { return act, nil } +func trimLastLineBreak(in string) string { + l := len(in) + if l > 2 && in[l-2:] == "\r\n" { + return in[:l-2] + } + if l > 1 && in[l-1:] == "\n" { + return in[:l-1] + } + if l > 1 && in[l-1:] == "\r" { + return in[:l-1] + } + return in +} + // HeaderField sends a single header field to the milter. // // Value should be the original field value without any unfolding applied. +// value may contain the last CR LF that ist the end marker of this header. // // HeaderEnd() must be called after the last field. // @@ -718,7 +733,7 @@ func (s *ClientSession) HeaderField(key, value string, macros map[MacroName]stri Code: wire.CodeHeader, } msg.Data = wire.AppendCString(msg.Data, key) - msg.Data = wire.AppendCString(msg.Data, value) + msg.Data = wire.AppendCString(msg.Data, trimLastLineBreak(value)) if err := s.writePacket(msg); err != nil { return nil, s.errorOut(fmt.Errorf("milter: header field: %w", err)) @@ -818,12 +833,11 @@ func (s *ClientSession) BodyChunk(chunk []byte) (*Action, error) { if s.state < clientStateHeaderEndCalled || s.state > clientStateBodyChunkCalled { return nil, s.errorOut(fmt.Errorf("milter: body: in wrong state %d", s.state)) } + s.state = clientStateBodyChunkCalled if s.skip { return &Action{Type: ActionContinue}, nil } - s.state = clientStateBodyChunkCalled - if s.ProtocolOption(OptNoBody) { return &Action{Type: ActionContinue}, nil } @@ -886,6 +900,8 @@ func (s *ClientSession) BodyReadFrom(r io.Reader) ([]ModifyAction, *Action, erro if scanner.Err() != nil { return nil, nil, scanner.Err() } + } else { + s.state = clientStateBodyChunkCalled } return s.End() diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..4534066 --- /dev/null +++ b/integration/README.md @@ -0,0 +1,155 @@ +# go-milter integration tests + +## How it works + +The integration test runner starts a receiving SMTP server and test milter servers. It then configures different MTAs to +use the test milter servers and send all emails to the receiving SMTP server. When all this is set up and running, +the test runner send the testcases as SMTP transactions to the MTA and checks if the right filter decision at the right +time was made and whether the outgoing SMTP message is as expected. + +## Testcases + +A testcase is a text file that has three parts: input steps, the expected milter decision (accept, reject etc.) and +optional output data (mail from, header etc.) that gets compared with the actual output of the MTA. + +### Input steps + +You can omit input steps. Necessary input steps get automatically added to the testcase. + +#### `HELO [hello-hostname]` + +Sends a HELO/EHLO to the SMTP server + +#### `STARTTLS` + +Start TLS encryption of connection + +#### `AUTH [user1@example.com|user2@example.com]` + +Authenticates SMTP connection. There are only two users hard-coded user1@example.com (password `password1`) and user2@example.com (password `password2`). + +#### `FROM args` + +Sends a `MAIL FROM` SMTP command. + +#### `TO args` + +Sends a `RCPT TO` SMTP command. + +#### `RESET` + +Sends a `RSET` SMTP command. + +#### `HEADER` + +Sends the `DATA` SMTP command and then the header. The header to send follows the `HEADER` line. The end of +the header is marked with a single `.` in a line (like in SMTP connections) + +#### `BODY` + +Sends the body part of the DATA. The end of the body part is also marked with a single `.`. + +### `DECISION [decision]@[step]` + +Every testcase needs to have a `DECISION`. Valid `decision`s are: `ACCEPT`, `TEMPFAIL`, `REJECT`, `DISCARD-OR-QUARANTINE` and `CUSTOM`. +If you specify `CUSTOM` then the lines after the `DECISION` line get parsed as a SMTP response and the mitler should +set this SMTP response. + +The `step` can be `HELO`, `FROM`, `TO`, `DATA`, `EOM` and `*`. If the step is omitted `*` is assumed. +`*` means that the decision can happen after any step. + +### Output + +If you specified `ACCEPT` as decision you can add `FROM`, `TO`, `HEADER` and `BODY` lines (see syntax above) after the `DECISION` line. +These values get compared with the actual result the MTA send to our receiving SMTP server. + +## How to add integration tests to your go-milter based mail filter + +You need docker since the test are run inside a docker container. + +Add a Makefile +```makefile +GO_MILTER_DIR := $(shell go list -f '{{.Dir}}' github.com/d--j/go-milter) + +integration: + docker build -q --progress=plain -t go-milter-integration "$(GO_MILTER_DIR)/integration/docker" && \ + docker run --rm -w /usr/src/root/integration -v $(PWD):/usr/src/root go-milter-integration \ + go run github.com/d--j/go-milter/integration/runner -filter '.*' ./tests + +.PHONY: integration +``` + +Add an `integration` directory. Execute the following inside: +```shell +go mod init +go mod edit -require github.com/d--j/go-milter +go mod edit -require github.com/d--j/go-milter/integration +go mod edit -replace $(cd .. && go list '{{.Path}}')=.. +mkdir tests +``` + +Tests consist of a test milter and testcases that get feed into an MTA that is configured to use the test milter. + +A test milter can look something like this: + +```go +package main + +import ( + "context" + + "github.com/d--j/go-milter/integration" + "github.com/d--j/go-milter/mailfilter" +) + +func main() { + integration.RequiredTags("auth-plain", "auth-no", "tls-starttls", "tls-no") + integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) { + return mailfilter.CustomErrorResponse(501, "Test"), nil + }, mailfilter.WithDecisionAt(mailfilter.DecisionAtMailFrom)) +} +``` + +A testcase for this milter would be: +``` +DECISION CUSTOM +501 Test +``` + +## How to handle dynamic data + +If your milter is time dependent or relies on external data you can use monkey pathing to make the output of your milter +static. E.g. the following sets a constant time for `time.Now` and mocks the SPF checks of your milter to static values: + +```go +package patches + +import ( + "net" + "strings" + "time" + + "blitiri.com.ar/go/spf" + "github.com/agiledragon/gomonkey/v2" +) + +var ConstantDate = time.Date(2023, time.January, 1, 12, 0, 0, 0, time.UTC) + +func Apply() *gomonkey.Patches { + return gomonkey. + ApplyFuncReturn(time.Now, ConstantDate). + ApplyFunc(spf.CheckHostWithSender, func(_ net.IP, helo, sender string, _ ...spf.Option) (spf.Result, error) { + if strings.HasSuffix(sender, "@example.com") || helo == "example.com" { + return spf.Pass, nil + } + if strings.HasSuffix(sender, "@example.net") || helo == "example.net" { + return spf.Fail, nil + } + return spf.None, nil + }) +} +``` + +The `Received` line that the MTA add contains dynamic data (date, queue id). Your test milter will see this dynamic header, +but before comparing the SMTP message with the testcase output data the test runner replaces the first +`Recieved` header with the static header `Received: placeholder`. diff --git a/integration/docker/Dockerfile b/integration/docker/Dockerfile new file mode 100644 index 0000000..353c04f --- /dev/null +++ b/integration/docker/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1-bullseye +RUN apt-get update -q \ + && apt-get install -y sudo syslog-ng sasl2-bin libsasl2-2 libsasl2-modules ssl-cert m4 expect tcl-expect cpio \ + && mkdir /pkgs && cd /pkgs \ + && apt-get download \ + postfix sendmail sendmail-base sendmail-bin sendmail-cf sensible-mda \ + libsigsegv2 maildrop libicu67 libnsl2 \ + courier-authlib libcourier-unicode4 liblockfile-bin liblockfile1 \ + libltdl7 libwrap0 lockfile-progs \ + && dpkg --force-all -i *.deb \ + && rm -rf /pkgs /var/lib/apt/lists/* +RUN mkdir /.cache && chmod 0777 /.cache +RUN git config --global --add safe.directory /usr/src/root +COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf +WORKDIR /usr/src/root/integration +CMD ["go", "run", "github.com/d--j/go-milter/integration/runner", "./tests"] diff --git a/integration/docker/syslog-ng.conf b/integration/docker/syslog-ng.conf new file mode 100644 index 0000000..63a99a3 --- /dev/null +++ b/integration/docker/syslog-ng.conf @@ -0,0 +1,15 @@ +@version: 3.28 +@include "scl.conf" + +source s_local { + internal(); +}; + +destination d_local { + file("/var/log/messages"); +}; + +log { + source(s_local); + destination(d_local); +}; diff --git a/integration/filter.go b/integration/filter.go new file mode 100644 index 0000000..7a2c5bb --- /dev/null +++ b/integration/filter.go @@ -0,0 +1,66 @@ +// Package integration has integration tests and utilities for integration tests. +package integration + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/d--j/go-milter/mailfilter" + "golang.org/x/tools/go/buildutil" +) + +var Network = flag.String("network", "", "network") +var Address = flag.String("address", "", "address") +var Tags []string + +const ExitSkip = 99 + +func init() { + flag.Var((*buildutil.TagsFlag)(&Tags), "tags", buildutil.TagsFlagDoc) +} + +func Test(decider mailfilter.DecisionModificationFunc, opts ...mailfilter.Option) { + if !flag.Parsed() { + flag.Parse() + } + if Network == nil || *Network == "" { + log.Fatal("no network specified") + } + if Address == nil || *Address == "" { + log.Fatal("no address specified") + } + filter, err := mailfilter.New(*Network, *Address, decider, opts...) + if err != nil { + log.Fatal(err) + } + log.Printf("Started milter on %s:%s", filter.Addr().Network(), filter.Addr().String()) + filter.Wait() +} + +func HasTag(tag string) bool { + if !flag.Parsed() { + flag.Parse() + } + for _, t := range Tags { + if t == tag { + return true + } + } + return false +} + +func Skip(reason string) { + log.Printf("skip test: %s", reason) + os.Exit(ExitSkip) +} + +func RequiredTags(tags ...string) { + for _, t := range tags { + if !HasTag(t) { + Skip(fmt.Sprintf("required tags not met: %s", strings.Join(tags, ","))) + } + } +} diff --git a/integration/go.mod b/integration/go.mod new file mode 100644 index 0000000..a4ff759 --- /dev/null +++ b/integration/go.mod @@ -0,0 +1,19 @@ +module github.com/d--j/go-milter/integration + +go 1.18 + +require ( + github.com/d--j/go-milter v0.6.0 + github.com/emersion/go-message v0.16.0 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.16.0 + golang.org/x/tools v0.1.12 +) + +require ( + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/text v0.7.0 // indirect +) + +replace github.com/d--j/go-milter => ../ diff --git a/integration/go.sum b/integration/go.sum new file mode 100644 index 0000000..8f013f1 --- /dev/null +++ b/integration/go.sum @@ -0,0 +1,18 @@ +github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4= +github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8= +github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/integration/mta/mock/mta.go b/integration/mta/mock/mta.go new file mode 100644 index 0000000..0609174 --- /dev/null +++ b/integration/mta/mock/mta.go @@ -0,0 +1,552 @@ +package main + +import ( + "bytes" + "crypto/tls" + "errors" + "flag" + "fmt" + "io" + "log" + "math/rand" + "net" + textproto2 "net/textproto" + "strconv" + "strings" + "time" + + "github.com/d--j/go-milter" + "github.com/emersion/go-smtp" +) + +type Rcpt struct { + Addr, Args string +} + +type Msg struct { + From, FromArgs string + Recipients []Rcpt + Data []byte +} + +var queue chan Msg + +func errorFromResp(resp *milter.Action) *smtp.SMTPError { + return &smtp.SMTPError{ + Code: int(resp.SMTPCode), + EnhancedCode: smtp.NoEnhancedCode, + Message: resp.SMTPReply[4:], + } +} + +// The Backend implements SMTP server methods. +type Backend struct { + client *milter.Client +} + +func (bkd *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) { + macros := milter.NewMacroBag() + macros.Set(milter.MacroMTAFullyQualifiedDomainName, "localhost.local") + macros.Set(milter.MacroDaemonName, "localhost.local") + macros.Set(milter.MacroIfName, "eth99") + addr, _, err := net.SplitHostPort(conn.Conn().LocalAddr().String()) + if err != nil { + return nil, err + } + macros.Set(milter.MacroIfAddr, addr) + s, err := bkd.client.Session(macros) + if err != nil { + return nil, err + } + addr, portS, err := net.SplitHostPort(conn.Conn().RemoteAddr().String()) + if err != nil { + return nil, err + } + port, err := strconv.ParseUint(portS, 10, 16) + if err != nil { + return nil, err + } + resp, err := s.Conn(addr, milter.FamilyInet, uint16(port), addr) + if err != nil { + return nil, err + } + if resp.StopProcessing() { + return nil, errorFromResp(resp) + } + if state, ok := conn.TLSConnectionState(); ok { + tlsVersion := map[uint16]string{ + tls.VersionTLS10: "TLSv1.0", + tls.VersionTLS11: "TLSv1.1", + tls.VersionTLS12: "TLSv1.2", + tls.VersionTLS13: "TLSv1.3", + }[state.Version] + if tlsVersion == "" { + tlsVersion = fmt.Sprintf("unknown(%x)", state.Version) + } + macros.Set(milter.MacroTlsVersion, tlsVersion) + cipher := tls.CipherSuiteName(state.CipherSuite) + bits := "256" + if strings.Contains(cipher, "AES_128") { + bits = "128" + } + macros.Set(milter.MacroCipher, cipher) + macros.Set(milter.MacroCipherBits, bits) + } else { + macros.Set(milter.MacroTlsVersion, "") + macros.Set(milter.MacroCipher, "") + macros.Set(milter.MacroCipherBits, "") + } + resp, err = s.Helo(conn.Hostname()) + if err != nil { + return nil, err + } + if resp.StopProcessing() { + return nil, errorFromResp(resp) + } + queueId := randSeq(10) + macros.Set(milter.MacroQueueId, queueId) + return &Session{ + macros: macros, + filter: s, + queueId: queueId, + }, nil +} + +var _ smtp.Backend = (*Backend)(nil) + +// A Session is returned after EHLO. +type Session struct { + macros *milter.MacroBag + filter *milter.ClientSession + discarded bool + queueId string + MailFrom, MailFromArgs string + Recipients []Rcpt + Header []byte + Body []byte + QuarantineReason *string +} + +func (s *Session) AuthPlain(username, password string) error { + found := false + if username == "user1@example.com" && password == "password1" { + found = true + } + if username == "user2@example.com" && password == "password2" { + found = true + } + if found { + s.macros.Set(milter.MacroAuthType, "plain") + s.macros.Set(milter.MacroAuthAuthen, username) + log.Printf("[%s] Authenticated as: %s", s.queueId, username) + return nil + } + return errors.New("invalid username or password") +} + +func (s *Session) handleMilter(resp *milter.Action, err error) error { + if err != nil { + return err + } + if resp.StopProcessing() { + return errorFromResp(resp) + } + if resp.Type == milter.ActionDiscard { + s.discarded = true + } + return nil +} +func parseMailOptions(opts *smtp.MailOptions) string { + var args []string + if opts.Body != "" { + args = append(args, fmt.Sprintf("BODY=%s", opts.Body)) + } + if opts.Size > 0 { + args = append(args, fmt.Sprintf("SIZE=%d", opts.Size)) + } + if opts.UTF8 { + args = append(args, "SMTPUTF8") + } + if opts.RequireTLS { + args = append(args, "REQUIRETLS") + } + if opts.Auth != nil { + args = append(args, fmt.Sprintf("AUTH=<%s>", *opts.Auth)) + } + return strings.Join(args, " ") +} + +func toMailOptions(arg string) *smtp.MailOptions { + var err error + opts := smtp.MailOptions{} + args := strings.Split(arg, " ") + set := false + for _, a := range args { + if a == "REQUIRETLS" { + opts.RequireTLS = true + set = true + } else if a == "SMTPUTF8" { + opts.UTF8 = true + set = true + } else if strings.HasPrefix(a, "BODY=") { + opts.Body = smtp.BodyType(a[5:]) + set = true + } else if strings.HasPrefix(a, "SIZE=") { + opts.Size, err = strconv.Atoi(a[5:]) + if err != nil { + panic(err) + } + set = true + } else if strings.HasPrefix(a, "AUTH=") { + auth := a[6 : len(a)-1] + opts.Auth = &auth + set = true + } + } + if set { + return &opts + } + return nil +} + +func (s *Session) Mail(from string, opts *smtp.MailOptions) error { + log.Printf("[%s] Mail from: %s", s.queueId, from) + s.MailFrom = from + s.MailFromArgs = parseMailOptions(opts) + return s.handleMilter(s.filter.Mail(s.MailFrom, s.MailFromArgs)) +} + +func (s *Session) Rcpt(to string) error { + log.Printf("[%s] Rcpt to: %s", s.queueId, to) + if s.discarded { + return nil + } + s.Recipients = append(s.Recipients, Rcpt{Addr: to}) + return s.handleMilter(s.filter.Rcpt(to, "")) +} + +func (s *Session) Data(r io.Reader) error { + needsDiscard := true + defer func() { + if needsDiscard { + _, _ = io.Copy(io.Discard, r) + } + }() + if s.discarded { + return nil + } + err := s.handleMilter(s.filter.DataStart()) + if err != nil { + return err + } + if s.discarded { + return nil + } + receivedHeader := strings.NewReader("Received: from mock ([127.0.0.1]) by mock with ESMTP for ; Fri, 03 Mar 2023 22:11:17 +0100\r\n") + data, err := io.ReadAll(io.MultiReader(receivedHeader, r)) + if err != nil { + return err + } + index := bytes.Index(data, []byte("\r\n\r\n")) + if index < 0 { + return fmt.Errorf("could not find end of header in %q", data) + } + s.Header = data[:index+4] + s.Body = data[index+4:] + if len(s.Body) == 0 { + return fmt.Errorf("empty body, header = %s", s.Header) + } + log.Printf("[%s] Data Lengths: Header: %d Body: %d", s.queueId, len(s.Header), len(s.Body)) + headers := splitHeaders(s.Header) + for _, hdr := range headers { + err = s.handleMilter(s.filter.HeaderField(hdr.key, string(hdr.raw[len(hdr.key)+1:]), nil)) + if err != nil { + return err + } + if s.discarded { + return nil + } + } + err = s.handleMilter(s.filter.HeaderEnd()) + if err != nil { + return err + } + if s.discarded { + return nil + } + + needsDiscard = false + modActions, resp, err := s.filter.BodyReadFrom(bytes.NewReader(s.Body)) + err = s.handleMilter(resp, err) + if err != nil { + return err + } + if s.discarded { + return nil + } + replacedBody := []byte(nil) + + for _, act := range modActions { + switch act.Type { + case milter.ActionChangeFrom: + log.Printf("[%s] ACT = ActionChangeFrom <%s> %s", s.queueId, act.From, act.FromArgs) + s.MailFrom = act.From + s.MailFromArgs = act.FromArgs + case milter.ActionDelRcpt: + log.Printf("[%s] ACT = ActionDelRcpt <%s>", s.queueId, act.Rcpt) + again: + for i, r := range s.Recipients { + if act.Rcpt == r.Addr { + s.Recipients = append(s.Recipients[:i], s.Recipients[i+1:]...) + goto again + } + } + case milter.ActionAddRcpt: + log.Printf("[%s] ACT = ActionAddRcpt <%s> %s", s.queueId, act.Rcpt, act.RcptArgs) + s.Recipients = append(s.Recipients, Rcpt{Addr: act.Rcpt, Args: act.RcptArgs}) + case milter.ActionReplaceBody: + log.Printf("[%s] ACT = ActionReplaceBody %q", s.queueId, act.Body) + replacedBody = append(replacedBody, act.Body...) + case milter.ActionQuarantine: + log.Printf("[%s] ACT = ActionQuarantine %q", s.queueId, act.Reason) + s.QuarantineReason = &act.Reason + case milter.ActionAddHeader: + log.Printf("[%s] ACT = ActionAddHeader %s %q", s.queueId, act.HeaderName, act.HeaderValue) + maybeSpace := "" + if len(act.HeaderValue) == 0 || (act.HeaderValue[0] != ' ' && act.HeaderValue[0] != '\t') { + maybeSpace = " " + } + raw := fmt.Sprintf("%s:%s%s\r\n", act.HeaderName, maybeSpace, act.HeaderValue) + headers = append(headers, &field{ + key: textproto2.CanonicalMIMEHeaderKey(act.HeaderName), + changeIdx: -1, + raw: []byte(raw), + }) + case milter.ActionInsertHeader: + log.Printf("[%s] ACT = ActionInsertHeader %d %s %q", s.queueId, act.HeaderIndex, act.HeaderName, act.HeaderValue) + maybeSpace := "" + if len(act.HeaderValue) == 0 || (act.HeaderValue[0] != ' ' && act.HeaderValue[0] != '\t') { + maybeSpace = " " + } + raw := fmt.Sprintf("%s:%s%s\r\n", act.HeaderName, maybeSpace, act.HeaderValue) + f := &field{ + key: textproto2.CanonicalMIMEHeaderKey(act.HeaderName), + changeIdx: -1, + raw: []byte(raw), + } + if act.HeaderIndex == 0 { + headers = append([]*field{f}, headers...) + } else if len(headers) < int(act.HeaderIndex)-1 { + headers = append(headers, f) + } else { + idx := int(act.HeaderIndex) - 1 + headers = append(headers[:idx], append([]*field{f}, headers[idx:]...)...) + } + case milter.ActionChangeHeader: + log.Printf("[%s] ACT = ActionChangeHeader %d %s %q", s.queueId, act.HeaderIndex, act.HeaderName, act.HeaderValue) + maybeSpace := "" + if len(act.HeaderValue) == 0 || (act.HeaderValue[0] != ' ' && act.HeaderValue[0] != '\t') { + maybeSpace = " " + } + raw := fmt.Sprintf("%s:%s%s\r\n", act.HeaderName, maybeSpace, act.HeaderValue) + key := textproto2.CanonicalMIMEHeaderKey(act.HeaderName) + for _, f := range headers { + if f.key == key && f.changeIdx == int(act.HeaderIndex) { + if act.HeaderValue == "" { + f.raw = nil + } else { + f.raw = []byte(raw) + } + break + } + } + } + } + if s.QuarantineReason != nil { + return nil + } + if replacedBody != nil { + s.Body = replacedBody + } + + data = nil + for _, hdr := range headers { + if hdr.raw != nil { + data = append(data, hdr.raw...) + } + } + if len(data) == 0 { + data = append(data, '\r', '\n') + } + data = append(data, '\r', '\n') + data = append(data, s.Body...) + + queue <- Msg{ + From: s.MailFrom, + FromArgs: s.MailFromArgs, + Recipients: s.Recipients, + Data: data, + } + + return nil +} + +func (s *Session) Reset() { + log.Printf("[%s] Reset", s.queueId) + _ = s.filter.Abort(nil) + s.Body = nil + s.Recipients = nil +} + +func (s *Session) Logout() error { + log.Printf("[%s] Logout", s.queueId) + return nil +} + +var _ smtp.Session = (*Session)(nil) + +type field struct { + key string + changeIdx int + raw []byte +} + +func splitHeaders(headerBytes []byte) (fields []*field) { + continuation := false + keyCounter := make(map[string]int) + var s []string + for i := 0; i < len(headerBytes); { + nextEnd := bytes.Index(headerBytes[i:], []byte("\r\n")) + if nextEnd < 0 { + panic("missing line ending") + } + nextEnd += 2 + s = append(s, string(headerBytes[i:i+nextEnd])) + var peek byte + if i+nextEnd+1 < len(headerBytes) { + peek = headerBytes[i+nextEnd+1] + } + continuation = peek == ' ' || peek == '\t' + if !continuation { + if s[0] == "\r\n" { + // end marker + } else { + keyIdx := strings.IndexRune(s[0], ':') + if keyIdx < 0 { + log.Print(s) + panic("key not found") + } + key := textproto2.CanonicalMIMEHeaderKey(strings.TrimSpace(s[0][:keyIdx])) + keyCounter[key] += 1 + fields = append(fields, &field{ + key: key, + changeIdx: keyCounter[key], + raw: []byte(strings.Join(s, "")), + }) + } + s = nil + } + i = i + nextEnd + } + return +} + +//goland:noinspection SpellCheckingInspection +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randSeq(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func sendQueue(nextHopAddr string) { +queue: + for { + msg := <-queue + // Connect to the remote SMTP server. + c, err := smtp.Dial(nextHopAddr) + if err != nil { + log.Print(err) + continue + } + + // Set the sender and recipient first + if err := c.Mail(msg.From, toMailOptions(msg.FromArgs)); err != nil { + log.Print(err) + continue + } + for _, rcpt := range msg.Recipients { + if err := c.Rcpt(rcpt.Addr); err != nil { + log.Print(err) + continue queue + } + } + + // Send the email body. + wc, err := c.Data() + if err != nil { + log.Print(err) + continue + } + if _, err := wc.Write(msg.Data); err != nil { + log.Print(err) + _ = wc.Close() + continue + } + if err = wc.Close(); err != nil { + log.Print(err) + continue + } + + // Send the QUIT command and close the connection. + if err = c.Quit(); err != nil { + log.Print(err) + } + } +} + +func main() { + var mtaAddr string + var milterAddr string + var nextHopAddr string + var tlsCert string + var tlsKey string + flag.StringVar(&mtaAddr, "mta", "", "mta address") + flag.StringVar(&milterAddr, "milter", "", "milter address") + flag.StringVar(&nextHopAddr, "next", "", "next hop address") + flag.StringVar(&tlsCert, "cert", "", "path to TLS cert") + flag.StringVar(&tlsKey, "key", "", "path to TLS key") + flag.Parse() + + queue = make(chan Msg, 20) + go sendQueue(nextHopAddr) + + s := smtp.NewServer(&Backend{client: milter.NewClient("tcp", milterAddr)}) + s.Addr = mtaAddr + s.Domain = "localhost" + s.ReadTimeout = 10 * time.Second + s.WriteTimeout = 10 * time.Second + s.MaxMessageBytes = 1024 * 1024 + s.MaxRecipients = 50 + s.AllowInsecureAuth = true + s.EnableSMTPUTF8 = true + s.EnableREQUIRETLS = true + + if tlsCert != "" && tlsKey != "" { + cer, err := tls.LoadX509KeyPair(tlsCert, tlsKey) + if err != nil { + log.Fatal(err) + } + s.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cer}, + } + } + + log.Println("Starting server at", s.Addr) + if err := s.ListenAndServe(); err != nil { + log.Fatal(err) + } +} diff --git a/integration/mta/mock/mta.sh b/integration/mta/mock/mta.sh new file mode 100755 index 0000000..cf285f2 --- /dev/null +++ b/integration/mta/mock/mta.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/../script.sh" + +if [ -z "$1" ]; then usage; fi + +if [ "tags" = "$1" ]; then + echo "exec-foreground" + echo "mta-mock" + echo "auth-no" + echo "auth-plain" + echo "tls-no" + echo "tls-starttls" + exit 0 +fi + +if [ "start" = "$1" ]; then + shift + parse_args "$@" + go build -o "$SCRATCH_DIR/mta.exe" -v "$SCRIPT_DIR" + exec "$SCRATCH_DIR/mta.exe" -mta ":$MTA_PORT" -next ":$RECEIVER_PORT" -milter ":$MILTER_PORT" -cert "$SCRATCH_DIR/../cert.pem" -key "$SCRATCH_DIR/../key.pem" +fi + +if [ "stop" = "$1" ]; then + shift + parse_args "$@" + exit 0 +fi + +usage "Unknown command $1" diff --git a/integration/mta/postfix/dhparam.pem b/integration/mta/postfix/dhparam.pem new file mode 100644 index 0000000..088f967 --- /dev/null +++ b/integration/mta/postfix/dhparam.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg== +-----END DH PARAMETERS----- \ No newline at end of file diff --git a/integration/mta/postfix/main.cf b/integration/mta/postfix/main.cf new file mode 100644 index 0000000..8d1d5df --- /dev/null +++ b/integration/mta/postfix/main.cf @@ -0,0 +1,61 @@ +compatibility_level=2 + +myhostname = mx.example.com +mydomain = localhost +myorigin = localhost +mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 0.0.0.0/0 [::]/0 +smtpd_banner = $myhostname ESMTP $mail_name (Integration Test) + +inet_protocols = ipv4 +inet_interfaces = 127.0.0.1 + +# No local delivery: +mydestination = +local_transport = error:5.1.1 Mailbox unavailable +alias_database = +alias_maps = +local_recipient_maps = + +# Everything goes back to relay +relayhost = [127.0.0.1]:%{RECEIVER_PORT} +smtpd_relay_restrictions = permit_mynetworks, reject +smtpd_recipient_restrictions = permit_mynetworks, reject + +# Various +biff = no +append_dot_mydomain = no +readme_directory = no +recipient_delimiter = + +smtputf8_enable = yes + +# TLS +smtpd_use_tls = yes +smtpd_tls_security_level = may +smtpd_tls_dh1024_param_file = $config_directory/dhparam.pem +smtpd_tls_cert_file = $config_directory/cert.pem +smtpd_tls_key_file = $config_directory/key.pem +smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3 +smtp_tls_mandatory_protocols=!SSLv2,!SSLv3 + +#SASL +smtpd_sasl_type = cyrus +cyrus_sasl_config_path = $config_directory/sasl +smtpd_sasl_path = smtpd +smtpd_sasl_auth_enable = yes +smtpd_sasl_local_domain = example.com +smtpd_sasl_security_options = noanonymous + +# Milter +smtpd_milters = inet:127.0.0.1:%{MILTER_PORT} +non_smtpd_milters = inet:127.0.0.1:%{MILTER_PORT} +milter_protocol = 6 +milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen} +milter_default_action = reject + +data_directory = %{SCRATCH_DIR}/data +queue_directory = %{SCRATCH_DIR}/queue + +maillog_file = /dev/stdout +sendmail_path = /usr/lib/sendmail +debug_peer_list = 127.0.0.1 +debug_peer_level = 4 diff --git a/integration/mta/postfix/master.cf b/integration/mta/postfix/master.cf new file mode 100644 index 0000000..cc14494 --- /dev/null +++ b/integration/mta/postfix/master.cf @@ -0,0 +1,31 @@ +# ========================================================================== +# service type private unpriv chroot wakeup maxproc command + args +# (yes) (yes) (no) (never) (100) +# ========================================================================== +%{MTA_PORT} inet n - y - - smtpd +smtpd pass - - y - - smtpd +dnsblog unix - - y - 0 dnsblog +pickup fifo n - y 60 1 pickup +cleanup unix n - y - 0 cleanup +qmgr fifo n - n 300 1 qmgr +tlsmgr unix - - y 1000? 1 tlsmgr +rewrite unix - - y - - trivial-rewrite +bounce unix - - y - 0 bounce +defer unix - - y - 0 bounce +trace unix - - y - 0 bounce +verify unix - - y - 1 verify +flush unix n - y 1000? 0 flush +proxymap unix - - n - - proxymap +proxywrite unix - - n - 1 proxymap +smtp unix - - y - - smtp +relay unix - - y - - smtp +showq unix n - y - - showq +error unix - - y - - error +retry unix - - y - - error +discard unix - - y - - discard +local unix - n n - - local +virtual unix - n n - - virtual +lmtp unix - - y - - lmtp +anvil unix - - y - 1 anvil +scache unix - - y - 1 scache +postlog unix-dgram n - n - 1 postlogd diff --git a/integration/mta/postfix/mta.sh b/integration/mta/postfix/mta.sh new file mode 100755 index 0000000..88a837b --- /dev/null +++ b/integration/mta/postfix/mta.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/../script.sh" + +if [ -z "$1" ]; then usage; fi + +if [ "tags" = "$1" ]; then + if ! command -v postfix >/dev/null; then + die "no postfix executable found" + fi + echo "exec-foreground" + echo "mta-mock" + echo "auth-no" + if [ -z "$SKIP_POSTFIX_AUTH" ]; then + echo "auth-plain" + fi + echo "tls-no" + echo "tls-starttls" + exit 0 +fi + +setup_chroot() { + POSTCONF="postconf -o inet_interfaces= -c $SCRATCH_DIR/conf" + # Make sure that the chroot environment is set up correctly. + umask 022 + queue_dir=$($POSTCONF -hx queue_directory) + cd "$queue_dir" || die "cannot cd into queue_dir" + + # copy the smtp CA path if specified + sca_path=$($POSTCONF -hx smtp_tls_CApath) + case "$sca_path" in + '') : ;; # no sca_path + $queue_dir/*) : ;; # skip stuff already in chroot + *) + if test -d "$sca_path"; then + dest_dir="$queue_dir/${sca_path#/}" + # strip any/all trailing / + while [ "${dest_dir%/}" != "${dest_dir}" ]; do + dest_dir="${dest_dir%/}" + done + new=0 + if test -d "$dest_dir"; then + # write to a new directory ... + dest_dir="${dest_dir}.NEW" + new=1 + fi + mkdir --parent ${dest_dir} + # handle files in subdirectories + (cd "$sca_path" && find . -name '*.pem' -not -xtype l -print0 | cpio -0pdL --quiet "$dest_dir") 2>/dev/null || + ( + echo failure copying certificates + exit 1 + ) + c_rehash "$dest_dir" >/dev/null 2>&1 + if [ "$new" = 1 ]; then + # and replace the old directory + rm -rf "${dest_dir%.NEW}" + mv "$dest_dir" "${dest_dir%.NEW}" + fi + fi + ;; + esac + + # copy the smtpd CA path if specified + dca_path=$($POSTCONF -hx smtpd_tls_CApath) + case "$dca_path" in + '') : ;; # no dca_path + $queue_dir/*) : ;; # skip stuff already in chroot + *) + if test -d "$dca_path"; then + dest_dir="$queue_dir/${dca_path#/}" + # strip any/all trailing / + while [ "${dest_dir%/}" != "${dest_dir}" ]; do + dest_dir="${dest_dir%/}" + done + new=0 + if test -d "$dest_dir"; then + # write to a new directory ... + dest_dir="${dest_dir}.NEW" + new=1 + fi + mkdir --parent ${dest_dir} + # handle files in subdirectories + (cd "$dca_path" && find . -name '*.pem' -not -xtype l -print0 | cpio -0pdL --quiet "$dest_dir") 2>/dev/null || + ( + echo failure copying certificates + exit 1 + ) + c_rehash "$dest_dir" >/dev/null 2>&1 + if [ "$new" = 1 ]; then + # and replace the old directory + rm -rf "${dest_dir%.NEW}" + mv "$dest_dir" "${dest_dir%.NEW}" + fi + fi + ;; + esac + + # if we're using unix:passwd.byname, then we need to add etc/passwd. + local_maps=$($POSTCONF -hx local_recipient_maps) + if [ "X$local_maps" != "X${local_maps#*unix:passwd.byname}" ]; then + if [ "X$local_maps" = "X${local_maps#*proxy:unix:passwd.byname}" ]; then + sed 's/^\([^:]*\):[^:]*/\1:x/' /etc/passwd >etc/passwd + chmod a+r etc/passwd + fi + fi + + FILES="etc/localtime etc/services etc/resolv.conf etc/hosts \ + etc/host.conf etc/nsswitch.conf etc/nss_mdns.config etc/sasldb2" + for file in $FILES; do + [ -d ${file%/*} ] || mkdir -p ${file%/*} + if [ -f /${file} ]; then rm -f ${file} && cp /${file} ${file}; fi + if [ -f ${file} ]; then chmod a+rX ${file}; fi + done + # ldaps needs this. debian bug 572841 + ( + echo /dev/random + echo /dev/urandom + ) | cpio -pdL --quiet . 2>/dev/null || true + rm -f usr/lib/zoneinfo/localtime + mkdir -p usr/lib/zoneinfo + ln -sf /etc/localtime usr/lib/zoneinfo/localtime + + LIBLIST=$(for name in gcc_s nss resolv; do + for f in /lib/*/lib${name}*.so* /lib/lib${name}*.so*; do + if [ -f "$f" ]; then echo ${f#/}; fi + done + done) + + if [ -n "$LIBLIST" ]; then + for f in $LIBLIST; do + rm -f "$f" + done + tar cf - -C / $LIBLIST 2>/dev/null | tar xf - + fi +} + +if [ "start" = "$1" ]; then + shift + parse_args "$@" + mkdir "$SCRATCH_DIR/conf" "$SCRATCH_DIR/conf/sasl" "$SCRATCH_DIR/data" "$SCRATCH_DIR/queue" || die "could not create $SCRATCH_DIR/{conf,data,queue}" + render_template <"$SCRIPT_DIR/main.cf" >"$SCRATCH_DIR/conf/main.cf" || die "could not create $SCRATCH_DIR/conf/main.cf" + render_template <"$SCRIPT_DIR/master.cf" >"$SCRATCH_DIR/conf/master.cf" || die "could not create $SCRATCH_DIR/conf/master.cf" + cp "$SCRIPT_DIR/dhparam.pem" "$SCRATCH_DIR/conf/dhparam.pem" || die "could not create $SCRATCH_DIR/conf/dhparam.pem" + cp "$SCRATCH_DIR/../cert.pem" "$SCRATCH_DIR/conf/cert.pem" || die "could not create $SCRATCH_DIR/conf/cert.pem" + cp "$SCRATCH_DIR/../key.pem" "$SCRATCH_DIR/conf/key.pem" || die "could not create $SCRATCH_DIR/conf/key.pem" + render_template <"$SCRIPT_DIR/smtpd.conf" >"$SCRATCH_DIR/conf/sasl/smtpd.conf" || die "could not create $SCRATCH_DIR/conf/sasl/smtpd.conf" + sudo -n -- chown -R postfix:postfix "$SCRATCH_DIR/data" || die "could not chown $SCRATCH_DIR/data" + sudo -n -- postfix -v -c "$SCRATCH_DIR/conf" check || die "postfix config check failed" + echo "password1" | sudo -n -- saslpasswd2 -c -p -u example.com user1 || die "cannot create SASL user user1" + echo "password2" | sudo -n -- saslpasswd2 -c -p -u example.com user2 || die "cannot create SASL user user2" + setup_chroot + sudo -n -- postfix -v -c "$SCRATCH_DIR/conf" start-fg + exit 0 +fi + +if [ "stop" = "$1" ]; then + shift + parse_args "$@" + sudo -n -- postfix -v -c "$SCRATCH_DIR/conf" stop + exit 0 +fi + +usage "Unknown command $1" diff --git a/integration/mta/postfix/smtpd.conf b/integration/mta/postfix/smtpd.conf new file mode 100644 index 0000000..e5f8b0c --- /dev/null +++ b/integration/mta/postfix/smtpd.conf @@ -0,0 +1,4 @@ +pwcheck_method: auxprop +auxprop_plugin: sasldb +mech_list: LOGIN PLAIN +sasldb_path: /etc/sasldb2 diff --git a/integration/mta/script.sh b/integration/mta/script.sh new file mode 100644 index 0000000..51072f9 --- /dev/null +++ b/integration/mta/script.sh @@ -0,0 +1,50 @@ + +die() { + while [ $# -gt 0 ]; do + echo "$1" + shift + done + exit 1 +} + +usage() { + die "$@" "Usage: $0 [tags|start|stop]" +} + +parse_args() { + while [ $# -gt 0 ]; do + case $1 in + -mtaPort) + MTA_PORT="$2" + shift + shift + ;; + -milterPort) + MILTER_PORT="$2" + shift + shift + ;; + -receiverPort) + RECEIVER_PORT="$2" + shift + shift + ;; + -scratchDir) + SCRATCH_DIR="$2" + shift + shift + ;; + *) + usage "unknown argument $1" + ;; + esac + done + if [ -z "$MTA_PORT" ] || [ -z "$MILTER_PORT" ] || [ -z "$RECEIVER_PORT" ] || [ -z "$SCRATCH_DIR" ]; then + usage "missing required arguments" + fi + export MTA_PORT MILTER_PORT RECEIVER_PORT SCRATCH_DIR +} + +render_template() { + awk '{while(match($0,"[%]{[^}]*}")) {var=substr($0,RSTART+2,RLENGTH -3);gsub("[%]{"var"}",ENVIRON[var])}}1' +} diff --git a/integration/mta/sendmail/mta.sh b/integration/mta/sendmail/mta.sh new file mode 100755 index 0000000..6c2e778 --- /dev/null +++ b/integration/mta/sendmail/mta.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/../script.sh" + +if [ -z "$1" ]; then usage; fi + +if [ "tags" = "$1" ]; then + if [ ! -x /usr/libexec/sendmail/sendmail ]; then + die "sendmail not installed" + fi + #echo "exec-foreground" + echo "mta-sendmail" + echo "auth-no" + #echo "auth-plain" + echo "tls-no" + #echo "tls-starttls" + exit 0 +fi + +if [ "start" = "$1" ]; then + shift + parse_args "$@" + render_template <"$SCRIPT_DIR/sendmail.cf" >"$SCRATCH_DIR/sendmail.cf" || die "could not create $SCRATCH_DIR/sendmail.cf" + mkdir "${SCRATCH_DIR}/mqueue" || die "could not create $SCRATCH_DIR/mqueue" + sudo -n -- chown smmta:smmsp "${SCRATCH_DIR}/mqueue" || die "could not chown $SCRATCH_DIR/mqueue" + sudo -n -- chmod u=rwx,g=rs,o= "${SCRATCH_DIR}/mqueue" || die "could not chmod $SCRATCH_DIR/mqueue" + sudo -n -- syslog-ng || die "could not start syslog-ng" + sudo -n -- /usr/libexec/sendmail/sendmail -bd "-C${SCRATCH_DIR}/sendmail.cf" + exit 0 +fi + +if [ "stop" = "$1" ]; then + shift + parse_args "$@" + # echo "SHUTDOWN" > "${SCRATCH_DIR}/smcontrol" + kill "$(head -n1 "${SCRATCH_DIR}/sendmail.pid")" + sleep 2 + cat /var/log/messages + exit 0 +fi + +usage "Unknown command $1" diff --git a/integration/mta/sendmail/sendmail.cf b/integration/mta/sendmail/sendmail.cf new file mode 100644 index 0000000..d668d7e --- /dev/null +++ b/integration/mta/sendmail/sendmail.cf @@ -0,0 +1,2011 @@ +# +# Copyright (c) 1998-2005 Richard Nelson. All Rights Reserved. +# +# This file is used to configure Sendmail for use with Debian systems. +# +# +# Copyright (c) 1998-2004, 2009, 2010 Proofpoint, Inc. and its suppliers. +# All rights reserved. +# Copyright (c) 1983, 1995 Eric P. Allman. All rights reserved. +# Copyright (c) 1988, 1993 +# The Regents of the University of California. All rights reserved. +# +# By using this file, you agree to the terms and conditions set +# forth in the LICENSE file which can be found at the top level of +# the sendmail distribution. +# +# + +###################################################################### +###################################################################### +##### +##### SENDMAIL CONFIGURATION FILE +##### +##### built by root@2db5de0e69e8 on Sat Mar 11 20:09:05 UTC 2023 +##### in / +##### using /usr/share/sendmail/cf/ as configuration include directory +##### +###################################################################### +##### +##### DO NOT EDIT THIS FILE! Only edit the source .mc file. +##### +###################################################################### +###################################################################### + +##### $Id: cfhead.m4,v 8.122 2013-11-22 20:51:13 ca Exp $ ##### +##### $Id: cf.m4,v 8.33 2013-11-22 20:51:13 ca Exp $ ##### +##### $Id: sendmail.mc, v 8.15.2-22ubuntu3 2021-12-09 00:18:01 cowboy Exp $ ##### +##### $Id: autoconf.m4, v 8.15.2-22ubuntu3 2021-12-09 00:18:01 cowboy Exp $ ##### +##### $Id: debian.m4, v 8.15.2-22ubuntu3 2021-12-09 00:18:01 cowboy Exp $ ##### +# +#------------------------------------------------------------------------- +# +# Undocumented features are available in Debian Sendmail 8.15.2-22ubuntu3. +# * none +# +# _FFR_ features are available in Debian Sendmail 8.15.2-22ubuntu3. +# * milter +# * -D_FFR_QUEUE_SCHED_DBG -D_FFR_SKIP_DOMAINS -D_FFR_NO_PIPE -D_FFR_SHM_STATUS -D_FFR_RHS -D_FFR_MAIL_MACRO -D_FFR_QUEUEDELAY=1 -D_FFR_BADRCPT_SHUTDOWN -D_FFR_RESET_MACRO_GLOBALS -D_FFR_TLS_EC +#------------------------------------------------------------------------- +# +# These _FFR_ features are for sendmail.mc processing +# + +#------------------------------------------------------------------------- +##### $Id: debian-mta.m4, v 8.15.2-22ubuntu3 2021-12-09 00:18:01 cowboy Exp $ ##### + + +##### $Id: no_default_msa.m4,v 8.3 2013-11-22 20:51:11 ca Exp $ ##### + +##### $Id: use_cw_file.m4,v 8.12 2013-11-22 20:51:11 ca Exp $ ##### + + +##### $Id: access_db.m4,v 8.28 2013-11-22 20:51:11 ca Exp $ ##### + +##### $Id: greet_pause.m4,v 1.5 2013-11-22 20:51:11 ca Exp $ ##### + +##### $Id: delay_checks.m4,v 8.9 2013-11-22 20:51:11 ca Exp $ ##### + +##### $Id: conncontrol.m4,v 1.5 2013-11-22 20:51:11 ca Exp $ ##### + + +##### $Id: ratecontrol.m4,v 1.6 2013-11-22 20:51:11 ca Exp $ ##### + + + +##### $Id: proto.m4,v 8.762 2013-11-22 20:51:13 ca Exp $ ##### + +# level 10 config file format +V10/Berkeley + +# override file safeties - setting this option compromises system security, +# addressing the actual file configuration problem is preferred +# need to set this before any file actions are encountered in the cf file +O DontBlameSendmail=,AssumeSafeChown,ForwardFileInGroupWritableDirPath,GroupWritableForwardFileSafe,GroupWritableIncludeFileSafe,IncludeFileInGroupWritableDirPath,DontWarnForwardFileInUnsafeDirPath,TrustStickyBit,NonRootSafeAddr,GroupWritableIncludeFile,GroupReadableDefaultAuthInfoFile + +# default LDAP map specification +# need to set this now before any LDAP maps are defined +#O LDAPDefaultSpec=-h localhost + +################## +# local info # +################## + +# my LDAP cluster +# need to set this before any LDAP lookups are done (including classes) +#D{sendmailMTACluster}$m + +Cwlocalhost +# file containing names of hosts for which we receive email +Fw/etc/mail/local-host-names %[^\#] + +# my official domain name +# ... define this only if sendmail cannot automatically determine your domain +#Dj$w.Foo.COM + +# host/domain names ending with a token in class P are canonical +CP. + +# "Smart" relay host (may be null) +DS + + +# operators that cannot be in local usernames (i.e., network indicators) +CO @ % ! + +# a class with just dot (for identifying canonical names) +C.. + +# a class with just a left bracket (for identifying domain literals) +C[[ + +# access_db acceptance class +C{Accept}OK RELAY + + +# Resolve map (to check if a host exists in check_mail) +Kresolve host -a -T +C{ResOk}OKR + + +# Hosts for which relaying is permitted ($=R) +FR-o /etc/mail/relay-domains %[^\#] + +# arithmetic map +Karith arith +# macro storage map +Kmacro macro +# possible values for TLS_connection in access map +C{Tls}VERIFY ENCR + + + + + +# dequoting map +Kdequote dequote + +# class E: names that should be exposed as from this host, even if we masquerade +# class L: names that should be delivered locally, even if we have a relay +# class M: domains that should be converted to $M +# class N: domains that should not be converted to $M +#CL root + + + +# my name for error messages +DnMAILER-DAEMON + + +# Access list database (for spam stomping) +Kaccess hash -T /etc/mail/access + +# Configuration version number +DZ8.15.2/Debian-22ubuntu3 + + +############### +# Options # +############### + +# strip message body to 7 bits on input? +O SevenBitInput=False + +# 8-bit data handling +#O EightBitMode=pass8 + +# wait for alias file rebuild (default units: minutes) +O AliasWait=10 + +# location of alias file +O AliasFile=/etc/mail/aliases + +# minimum number of free blocks on filesystem +O MinFreeBlocks=100 + +# maximum message size +#O MaxMessageSize=0 + +# substitution for space (blank) characters +O BlankSub=. + +# avoid connecting to "expensive" mailers on initial submission? +O HoldExpensive=False + +# checkpoint queue runs after every N successful deliveries +#O CheckpointInterval=10 + +# default delivery mode +O DeliveryMode=background + +# error message header/file +#O ErrorHeader=/etc/mail/error-header + +# error mode +#O ErrorMode=print + +# save Unix-style "From_" lines at top of header? +#O SaveFromLine=False + +# queue file mode (qf files) +O QueueFileMode=0640 + +# temporary file mode +O TempFileMode=0640 + +# match recipients against GECOS field? +#O MatchGECOS=False + +# maximum hop count +O MaxHopCount=100 + +# location of help file +O HelpFile=/etc/mail/helpfile + +# ignore dots as terminators in incoming messages? +#O IgnoreDots=False + +# name resolver options +O ResolverOptions=+WorkAroundBrokenAAAA + +# deliver MIME-encapsulated error messages? +O SendMimeErrors=True + +# Forward file search path +O ForwardPath=$z/.forward.$w:$z/.forward + +# open connection cache size +O ConnectionCacheSize=2 + +# open connection cache timeout +O ConnectionCacheTimeout=5m + +# persistent host status directory +#O HostStatusDirectory=.hoststat + +# single thread deliveries (requires HostStatusDirectory)? +#O SingleThreadDelivery=False + +# use Errors-To: header? +O UseErrorsTo=False + +# use compressed IPv6 address format? +#O UseCompressedIPv6Addresses + +# log level +O LogLevel=9 + +# send to me too, even in an alias expansion? +O MeToo=True + +# verify RHS in newaliases? +O CheckAliases=False + +# default messages to old style headers if no special punctuation? +O OldStyleHeaders=True + +# SMTP daemon options + +O DaemonPortOptions=Family=inet, Name=MTA-v4, Port=%{MTA_PORT}, Addr=127.0.0.1, InputMailFilters=milter +#O DaemonPortOptions=Family=inet, Name=MSP-v4, Port=submission, M=Ea, Addr=127.0.0.1 + +# SMTP client options +#O ClientPortOptions=Family=inet, Address=0.0.0.0 + +# Modifiers to define {daemon_flags} for direct submissions +#O DirectSubmissionModifiers + +# Use as mail submission program? See sendmail/SECURITY +#O UseMSP + +# privacy flags +O PrivacyOptions=needmailhelo,needexpnhelo,needvrfyhelo,restrictqrun,restrictexpand,nobodyreturn,authwarnings + +# who (if anyone) should get extra copies of error messages +#O PostmasterCopy=Postmaster + +# slope of queue-only function +#O QueueFactor=600000 + +# limit on number of concurrent queue runners +#O MaxQueueChildren + +# maximum number of queue-runners per queue-grouping with multiple queues +O MaxRunnersPerQueue=5 + +# priority of queue runners (nice(3)) +#O NiceQueueRun + +# shall we sort the queue by hostname first? +#O QueueSortOrder=priority + +# increase milter log level (breaks sendmail?) +#O Milter.LogLevel=99 + +# minimum time in queue before retry +#O MinQueueAge=30m + +# maximum time in queue before retry (if > 0; only for exponential delay) +#O MaxQueueAge + +# how many jobs can you process in the queue? +#O MaxQueueRunSize=0 + +# perform initial split of envelope without checking MX records +#O FastSplit=1 + +# queue directory +O QueueDirectory=%{SCRATCH_DIR}/mqueue + +# key for shared memory; 0 to turn off, -1 to auto-select +#O SharedMemoryKey=0 + +# file to store auto-selected key for shared memory (SharedMemoryKey = -1) +#O SharedMemoryKeyFile + +# timeouts (many of these) +#O Timeout.initial=5m +#O Timeout.connect=5m +#O Timeout.aconnect=0s +O Timeout.iconnect=2m +#O Timeout.helo=5m +O Timeout.mail=2m +#O Timeout.rcpt=1h +O Timeout.datainit=2m +#O Timeout.datablock=1h +#O Timeout.datafinal=1h +O Timeout.rset=1m +O Timeout.quit=2m +#O Timeout.misc=2m +O Timeout.command=5m +O Timeout.ident=5s +#O Timeout.fileopen=60s +#O Timeout.control=2m +O Timeout.queuereturn=5d +#O Timeout.queuereturn.normal=5d +#O Timeout.queuereturn.urgent=2d +#O Timeout.queuereturn.non-urgent=7d +#O Timeout.queuereturn.dsn=5d +O Timeout.queuewarn=4h +#O Timeout.queuewarn.normal=4h +#O Timeout.queuewarn.urgent=1h +#O Timeout.queuewarn.non-urgent=12h +#O Timeout.queuewarn.dsn=4h +#O Timeout.hoststatus=30m +#O Timeout.resolver.retrans=5s +#O Timeout.resolver.retrans.first=5s +#O Timeout.resolver.retrans.normal=5s +#O Timeout.resolver.retry=4 +#O Timeout.resolver.retry.first=4 +#O Timeout.resolver.retry.normal=4 +#O Timeout.lhlo=2m +#O Timeout.auth=10m +#O Timeout.starttls=1h + +# time for DeliverBy; extension disabled if less than 0 +#O DeliverByMin=0 + +# should we not prune routes in route-addr syntax addresses? +#O DontPruneRoutes=False + +# queue up everything before forking? +O SuperSafe=True + +# status file +O StatusFile=%{SCRATCH_DIR}/sendmail.st + +# time zone handling: +# if undefined, use system default +# if defined but null, use TZ envariable passed in +# if defined and non-null, use that info +#O TimeZoneSpec= + +# default UID (can be username or userid:groupid) +O DefaultUser=mail:mail + +# list of locations of user database file (null means no lookup) +#O UserDatabaseSpec=/etc/mail/userdb + +# fallback MX host +#O FallbackMXhost=fall.back.host.net + +# fallback smart host +#O FallbackSmartHost=fall.back.host.net + +# if we are the best MX host for a site, try it directly instead of config err +#O TryNullMXList=False + +# load average at which we just queue messages +#O QueueLA=8 + +# load average at which we refuse connections +#O RefuseLA=12 + +# log interval when refusing connections for this long +#O RejectLogInterval=3h + +# load average at which we delay connections; 0 means no limit +#O DelayLA=0 + +# maximum number of children we allow at one time +O MaxDaemonChildren=0 + +# maximum number of new connections per second +O ConnectionRateThrottle=15 + +# Width of the window +O ConnectionRateWindowSize=10m + +# work recipient factor +#O RecipientFactor=30000 + +# deliver each queued job in a separate process? +#O ForkEachJob=False + +# work class factor +#O ClassFactor=1800 + +# work time factor +#O RetryFactor=90000 + +# default character set +#O DefaultCharSet=unknown-8bit + +# service switch file (name hardwired on Solaris, Ultrix, OSF/1, others) +#O ServiceSwitchFile=/etc/mail/service.switch + +# hosts file (normally /etc/hosts) +#O HostsFile=/etc/hosts + +# dialup line delay on connection failure +#O DialDelay=0s + +# action to take if there are no recipients in the message +#O NoRecipientAction=none + +# chrooted environment for writing to files +O SafeFileEnvironment=/ + +# are colons OK in addresses? +#O ColonOkInAddr=True + +# shall I avoid expanding CNAMEs (violates protocols)? +#O DontExpandCnames=False + +# SMTP initial login message (old $e macro) +O SmtpGreetingMessage=$j Sendmail $v/$Z; $b; (No UCE/UBE) $?{client_addr}logging access from: ${client_name}(${client_resolve})-$_$. + +# UNIX initial From header format (old $l macro) +O UnixFromLine=From $g $d + +# From: lines that have embedded newlines are unwrapped onto one line +#O SingleLineFromHeader=False + +# Allow HELO SMTP command that does not include a host name +O AllowBogusHELO=True + +# Characters to be quoted in a full name phrase (@,;:\()[] are automatic) +O MustQuoteChars=.' + +# delimiter (operator) characters (old $o macro) +O OperatorChars=.:%@!^/[]+ + +# shall I avoid calling initgroups(3) because of high NIS costs? +#O DontInitGroups=False + +# are group-writable :include: and .forward files (un)trustworthy? +# True (the default) means they are not trustworthy. +#O UnsafeGroupWrites=True + + +# where do errors that occur when sending errors get sent? +#O DoubleBounceAddress=postmaster + +# issue temporary errors (4xy) instead of permanent errors (5xy)? +#O SoftBounce=False + +# where to save bounces if all else fails +O DeadLetterDrop=%{SCRATCH_DIR}/dead.letter + +# what user id do we assume for the majority of the processing? +#O RunAsUser=sendmail + +# maximum number of recipients per SMTP envelope +#O MaxRecipientsPerMessage=0 + +# limit the rate recipients per SMTP envelope are accepted +# once the threshold number of recipients have been rejected +O BadRcptThrottle=3 + + +# shall we get local names from our installed interfaces? +#O DontProbeInterfaces=False + +# Return-Receipt-To: header implies DSN request +O RrtImpliesDsn=False + +# override connection address (for testing) +#O ConnectOnlyTo=0.0.0.0 + +# Trusted user for file ownership and starting the daemon +O TrustedUser=smmta + +# Control socket for daemon management +O ControlSocketName=%{SCRATCH_DIR}/smcontrol + +# Maximum MIME header length to protect MUAs +#O MaxMimeHeaderLength=0/0 + +# Maximum length of the sum of all headers +#O MaxHeadersLength=32768 + +# Maximum depth of alias recursion +#O MaxAliasRecursion=10 + +# location of pid file +O PidFile=%{SCRATCH_DIR}/sendmail.pid + +# Prefix string for the process title shown on 'ps' listings +O ProcessTitlePrefix=MTA + +# Data file (df) memory-buffer file maximum size +#O DataFileBufferSize=4096 + +# Transcript file (xf) memory-buffer file maximum size +#O XscriptFileBufferSize=4096 + +# lookup type to find information about local mailboxes +#O MailboxDatabase=pw + +# override compile time flag REQUIRES_DIR_FSYNC +#O RequiresDirfsync=true + +# list of authentication mechanisms +#O AuthMechanisms=EXTERNAL GSSAPI KERBEROS_V4 DIGEST-MD5 CRAM-MD5 + +# Authentication realm +#O AuthRealm + +# default authentication information for outgoing connections +#O DefaultAuthInfo=/etc/mail/default-auth-info + +# SMTP AUTH flags +#O AuthOptions + +# SMTP AUTH maximum encryption strength +#O AuthMaxBits + +# SMTP STARTTLS server options +#O TLSSrvOptions + +# SSL cipherlist +#O CipherList +# server side SSL options +#O ServerSSLOptions +# client side SSL options +#O ClientSSLOptions + +# Input mail filters +#O InputMailFilters + + +# CA directory +#O CACertPath +# CA file +#O CACertFile +# Server Cert +#O ServerCertFile +# Server private key +#O ServerKeyFile +# Client Cert +#O ClientCertFile +# Client private key +#O ClientKeyFile +# File containing certificate revocation lists +#O CRLFile +# DHParameters (only required if DSA/DH is used) +#O DHParameters +# Random data source (required for systems without /dev/urandom under OpenSSL) +#O RandFile +# fingerprint algorithm (digest) to use for the presented cert +#O CertFingerprintAlgorithm + +# Maximum number of "useless" commands before slowing down +#O MaxNOOPCommands=20 + +# Name to use for EHLO (defaults to $j) +#O HeloName + + + +############################ +# QUEUE GROUP DEFINITIONS # +############################ + + +########################### +# Message precedences # +########################### + +Pfirst-class=0 +Pspecial-delivery=100 +Plist=-30 +Pbulk=-60 +Pjunk=-100 + +##################### +# Trusted users # +##################### + +# this is equivalent to setting class "t" +#Ft/etc/mail/trusted-users %[^\#] +Troot +Tdaemon +Tuucp + +######################### +# Format of headers # +######################### + +H?P?Return-Path: <$g> +HReceived: $?sfrom $s $.$?_($?s$|from $.$_) + $.$?{auth_type}(authenticated$?{auth_ssf} bits=${auth_ssf}$.) + $.by $j ($v/$Z)$?r with $r$. id $i$?{tls_version} + (version=${tls_version} cipher=${cipher} bits=${cipher_bits} verify=${verify})$.$?u + for $u; $|; + $.$b +H?D?Resent-Date: $a +H?D?Date: $a +H?F?Resent-From: $?x$x <$g>$|$g$. +H?F?From: $?x$x <$g>$|$g$. +H?x?Full-Name: $x +# HPosted-Date: $a +# H?l?Received-Date: $b +H?M?Resent-Message-Id: <$t.$i@$j> +H?M?Message-Id: <$t.$i@$j> + +# + +###################################################################### +###################################################################### +##### +##### REWRITING RULES +##### +###################################################################### +###################################################################### + +############################################ +### Ruleset 3 -- Name Canonicalization ### +############################################ +Scanonify=3 + +# handle null input (translate to <@> special case) +R$@ $@ <@> + +# strip group: syntax (not inside angle brackets!) and trailing semicolon +R$* $: $1 <@> mark addresses +R$* < $* > $* <@> $: $1 < $2 > $3 unmark +R@ $* <@> $: @ $1 unmark @host:... +R$* [ IPv6 : $+ ] <@> $: $1 [ IPv6 : $2 ] unmark IPv6 addr +R$* :: $* <@> $: $1 :: $2 unmark node::addr +R:include: $* <@> $: :include: $1 unmark :include:... +R$* : $* [ $* ] $: $1 : $2 [ $3 ] <@> remark if leading colon +R$* : $* <@> $: $2 strip colon if marked +R$* <@> $: $1 unmark +R$* ; $1 strip trailing semi +R$* < $+ :; > $* $@ $2 :; <@> catch +R$* < $* ; > $1 < $2 > bogus bracketed semi + +# null input now results from list:; syntax +R$@ $@ :; <@> + +# strip angle brackets -- note RFC733 heuristic to get innermost item +R$* $: < $1 > housekeeping <> +R$+ < $* > < $2 > strip excess on left +R< $* > $+ < $1 > strip excess on right +R<> $@ < @ > MAIL FROM:<> case +R< $+ > $: $1 remove housekeeping <> + +# strip route address <@a,@b,@c:user@d> -> +R@ $+ , $+ $2 +R@ [ $* ] : $+ $2 +R@ $+ : $+ $2 + +# find focus for list syntax +R $+ : $* ; @ $+ $@ $>Canonify2 $1 : $2 ; < @ $3 > list syntax +R $+ : $* ; $@ $1 : $2; list syntax + +# find focus for @ syntax addresses +R$+ @ $+ $: $1 < @ $2 > focus on domain +R$+ < $+ @ $+ > $1 $2 < @ $3 > move gaze right +R$+ < @ $+ > $@ $>Canonify2 $1 < @ $2 > already canonical + + +# convert old-style addresses to a domain-based address +R$- ! $+ $@ $>Canonify2 $2 < @ $1 .UUCP > resolve uucp names +R$+ . $- ! $+ $@ $>Canonify2 $3 < @ $1 . $2 > domain uucps +R$+ ! $+ $@ $>Canonify2 $2 < @ $1 .UUCP > uucp subdomains + +# if we have % signs, take the rightmost one +R$* % $* $1 @ $2 First make them all @s. +R$* @ $* @ $* $1 % $2 @ $3 Undo all but the last. + +R$* @ $* $@ $>Canonify2 $1 < @ $2 > Insert < > and finish + +# else we must be a local name +R$* $@ $>Canonify2 $1 + + +################################################ +### Ruleset 96 -- bottom half of ruleset 3 ### +################################################ + +SCanonify2=96 + +# handle special cases for local names +R$* < @ localhost > $* $: $1 < @ $j . > $2 no domain at all +R$* < @ localhost . $m > $* $: $1 < @ $j . > $2 local domain +R$* < @ localhost . UUCP > $* $: $1 < @ $j . > $2 .UUCP domain + +# check for IPv4/IPv6 domain literal +R$* < @ [ $+ ] > $* $: $1 < @@ [ $2 ] > $3 mark [addr] +R$* < @@ $=w > $* $: $1 < @ $j . > $3 self-literal +R$* < @@ $+ > $* $@ $1 < @ $2 > $3 canon IP addr + + + + + +# if really UUCP, handle it immediately + +# try UUCP traffic as a local address +R$* < @ $+ . UUCP > $* $: $1 < @ $[ $2 $] . UUCP . > $3 +R$* < @ $+ . . UUCP . > $* $@ $1 < @ $2 . > $3 + +# hostnames ending in class P are always canonical +R$* < @ $* $=P > $* $: $1 < @ $2 $3 . > $4 +R$* < @ $* $~P > $* $: $&{daemon_flags} $| $1 < @ $2 $3 > $4 +R$* CC $* $| $* < @ $+.$+ > $* $: $3 < @ $4.$5 . > $6 +R$* CC $* $| $* $: $3 +# pass to name server to make hostname canonical +R$* $| $* < @ $* > $* $: $2 < @ $[ $3 $] > $4 +R$* $| $* $: $2 + +# local host aliases and pseudo-domains are always canonical +R$* < @ $=w > $* $: $1 < @ $2 . > $3 +R$* < @ $=M > $* $: $1 < @ $2 . > $3 +R$* < @ $* . . > $* $1 < @ $2 . > $3 + + +################################################## +### Ruleset 4 -- Final Output Post-rewriting ### +################################################## +Sfinal=4 + +R$+ :; <@> $@ $1 : handle +R$* <@> $@ handle <> and list:; + +# strip trailing dot off possibly canonical name +R$* < @ $+ . > $* $1 < @ $2 > $3 + +# eliminate internal code +R$* < @ *LOCAL* > $* $1 < @ $j > $2 + +# externalize local domain info +R$* < $+ > $* $1 $2 $3 defocus +R@ $+ : @ $+ : $+ @ $1 , @ $2 : $3 canonical +R@ $* $@ @ $1 ... and exit + +# UUCP must always be presented in old form +R$+ @ $- . UUCP $2!$1 u@h.UUCP => h!u + +# delete duplicate local names +R$+ % $=w @ $=w $1 @ $2 u%host@host => u@host + + + +############################################################## +### Ruleset 97 -- recanonicalize and call ruleset zero ### +### (used for recursive calls) ### +############################################################## + +SRecurse=97 +R$* $: $>canonify $1 +R$* $@ $>parse $1 + + +###################################### +### Ruleset 0 -- Parse Address ### +###################################### + +Sparse=0 + +R$* $: $>Parse0 $1 initial parsing +R<@> $#local $: <@> special case error msgs +R$* $: $>ParseLocal $1 handle local hacks +R$* $: $>Parse1 $1 final parsing + +# +# Parse0 -- do initial syntax checking and eliminate local addresses. +# This should either return with the (possibly modified) input +# or return with a #error mailer. It should not return with a +# #mailer other than the #error mailer. +# + +SParse0 +R<@> $@ <@> special case error msgs +R$* : $* ; <@> $#error $@ 5.1.3 $: "553 List:; syntax illegal for recipient addresses" +R@ <@ $* > < @ $1 > catch "@@host" bogosity +R<@ $+> $#error $@ 5.1.3 $: "553 User address required" +R$+ <@> $#error $@ 5.1.3 $: "553 Hostname required" +R$* $: <> $1 +R<> $* < @ [ $* ] : $+ > $* $1 < @ [ $2 ] : $3 > $4 +R<> $* < @ [ $* ] , $+ > $* $1 < @ [ $2 ] , $3 > $4 +R<> $* < @ [ $* ] $+ > $* $#error $@ 5.1.2 $: "553 Invalid address" +R<> $* < @ [ $+ ] > $* $1 < @ [ $2 ] > $3 +R<> $* <$* : $* > $* $#error $@ 5.1.3 $: "553 Colon illegal in host name part" +R<> $* $1 +R$* < @ . $* > $* $#error $@ 5.1.2 $: "553 Invalid host name" +R$* < @ $* .. $* > $* $#error $@ 5.1.2 $: "553 Invalid host name" +R$* < @ $* @ > $* $#error $@ 5.1.2 $: "553 Invalid route address" +R$* @ $* < @ $* > $* $#error $@ 5.1.3 $: "553 Invalid route address" +R$* , $~O $* $#error $@ 5.1.3 $: "553 Invalid route address" + + +# now delete the local info -- note $=O to find characters that cause forwarding +R$* < @ > $* $@ $>Parse0 $>canonify $1 user@ => user +R< @ $=w . > : $* $@ $>Parse0 $>canonify $2 @here:... -> ... +R$- < @ $=w . > $: $(dequote $1 $) < @ $2 . > dequote "foo"@here +R< @ $+ > $#error $@ 5.1.3 $: "553 User address required" +R$* $=O $* < @ $=w . > $@ $>Parse0 $>canonify $1 $2 $3 ...@here -> ... +R$- $: $(dequote $1 $) < @ *LOCAL* > dequote "foo" +R< @ *LOCAL* > $#error $@ 5.1.3 $: "553 User address required" +R$* $=O $* < @ *LOCAL* > + $@ $>Parse0 $>canonify $1 $2 $3 ...@*LOCAL* -> ... +R$* < @ *LOCAL* > $: $1 + + +# +# Parse1 -- the bottom half of ruleset 0. +# + +SParse1 + +# handle numeric address spec +R$* < @ [ $+ ] > $* $: $>ParseLocal $1 < @ [ $2 ] > $3 numeric internet spec +R$* < @ [ $+ ] > $* $: $1 < @ [ $2 ] : $S > $3 Add smart host to path +R$* < @ [ $+ ] : > $* $#esmtp $@ [$2] $: $1 < @ [$2] > $3 no smarthost: send +R$* < @ [ $+ ] : $- : $*> $* $#$3 $@ $4 $: $1 < @ [$2] > $5 smarthost with mailer +R$* < @ [ $+ ] : $+ > $* $#esmtp $@ $3 $: $1 < @ [$2] > $4 smarthost without mailer + + +# short circuit local delivery so forwarded email works + + +R$=L < @ $=w . > $#local $: @ $1 special local names +R$+ < @ $=w . > $#local $: $1 regular local name + + +# resolve remotely connected UUCP links (if any) + +# resolve fake top level domains by forwarding to other hosts + + + +# pass names that still have a host to a smarthost (if defined) +R$* < @ $* > $* $: $>MailerToTriple < $S > $1 < @ $2 > $3 glue on smarthost name + +# deal with other remote names +R$* < @$* > $* $#esmtp $@ $2 $: $1 < @ $2 > $3 user@host.domain + +# handle locally delivered names +R$=L $#local $: @ $1 special local names +R$+ $#local $: $1 regular local names + + + +########################################################################### +### Ruleset 5 -- special rewriting after aliases have been expanded ### +########################################################################### + +SLocal_localaddr +Slocaladdr=5 +R$+ $: $1 $| $>"Local_localaddr" $1 +R$+ $| $#ok $@ $1 no change +R$+ $| $#$* $#$2 +R$+ $| $* $: $1 + + + + +# deal with plussed users so aliases work nicely +R$+ + * $#local $@ $&h $: $1 +R$+ + $* $#local $@ + $2 $: $1 + * + +# prepend an empty "forward host" on the front +R$+ $: <> $1 + + + +R< > $+ $: < > < $1 <> $&h > nope, restore +detail + +R< > < $+ <> + $* > $: < > < $1 + $2 > check whether +detail +R< > < $+ <> $* > $: < > < $1 > else discard +R< > < $+ + $* > $* < > < $1 > + $2 $3 find the user part +R< > < $+ > + $* $#local $@ $2 $: @ $1 strip the extra + +R< > < $+ > $@ $1 no +detail +R$+ $: $1 <> $&h add +detail back in + +R$+ <> + $* $: $1 + $2 check whether +detail +R$+ <> $* $: $1 else discard +R< local : $* > $* $: $>MailerToTriple < local : $1 > $2 no host extension +R< error : $* > $* $: $>MailerToTriple < error : $1 > $2 no host extension + +R< $~[ : $+ > $+ $: $>MailerToTriple < $1 : $2 > $3 < @ $2 > + +R< $+ > $+ $@ $>MailerToTriple < $1 > $2 < @ $1 > + + +################################################################### +### Ruleset 95 -- canonify mailer:[user@]host syntax to triple ### +################################################################### + +SMailerToTriple=95 +R< > $* $@ $1 strip off null relay +R< error : $-.$-.$- : $+ > $* $#error $@ $1.$2.$3 $: $4 +R< error : $- : $+ > $* $#error $@ $(dequote $1 $) $: $2 +R< error : $+ > $* $#error $: $1 +R< local : $* > $* $>CanonLocal < $1 > $2 +R< $~[ : $+ @ $+ > $*<$*>$* $# $1 $@ $3 $: $2<@$3> use literal user +R< $~[ : $+ > $* $# $1 $@ $2 $: $3 try qualified mailer +R< $=w > $* $@ $2 delete local host +R< $+ > $* $#relay $@ $1 $: $2 use unqualified mailer + +################################################################### +### Ruleset CanonLocal -- canonify local: syntax ### +################################################################### + +SCanonLocal +# strip local host from routed addresses +R< $* > < @ $+ > : $+ $@ $>Recurse $3 +R< $* > $+ $=O $+ < @ $+ > $@ $>Recurse $2 $3 $4 + +# strip trailing dot from any host name that may appear +R< $* > $* < @ $* . > $: < $1 > $2 < @ $3 > + +# handle local: syntax -- use old user, either with or without host +R< > $* < @ $* > $* $#local $@ $1@$2 $: $1 +R< > $+ $#local $@ $1 $: $1 + +# handle local:user@host syntax -- ignore host part +R< $+ @ $+ > $* < @ $* > $: < $1 > $3 < @ $4 > + +# handle local:user syntax +R< $+ > $* <@ $* > $* $#local $@ $2@$3 $: $1 +R< $+ > $* $#local $@ $2 $: $1 + +################################################################### +### Ruleset 93 -- convert header names to masqueraded form ### +################################################################### + +SMasqHdr=93 + + +# do not masquerade anything in class N +R$* < @ $* $=N . > $@ $1 < @ $2 $3 . > + +R$* < @ *LOCAL* > $@ $1 < @ $j . > + +################################################################### +### Ruleset 94 -- convert envelope names to masqueraded form ### +################################################################### + +SMasqEnv=94 +R$* < @ *LOCAL* > $* $: $1 < @ $j . > $2 + +################################################################### +### Ruleset 98 -- local part of ruleset zero (can be null) ### +################################################################### + +SParseLocal=98 + + + + + +###################################################################### +### D: LookUpDomain -- search for domain in access database +### +### Parameters: +### <$1> -- key (domain name) +### <$2> -- default (what to return if not found in db) +### <$3> -- mark (must be <(!|+) single-token>) +### ! does lookup only with tag +### + does lookup with and without tag +### <$4> -- passthru (additional data passed unchanged through) +###################################################################### + +SD +R<$*> <$+> <$- $-> <$*> $: < $(access $4:$1 $: ? $) > <$1> <$2> <$3 $4> <$5> +R <$+> <$+> <+ $-> <$*> $: < $(access $1 $: ? $) > <$1> <$2> <+ $3> <$4> +R <$+> <$+> <$- $-> <$*> $@ <$2> <$5> +R <[$+.$-]> <$+> <$- $-> <$*> $@ $>D <[$1]> <$3> <$4 $5> <$6> +R <[$+::$-]> <$+> <$- $-> <$*> $: $>D <[$1]> <$3> <$4 $5> <$6> +R <[$+:$-]> <$+> <$- $-> <$*> $: $>D <[$1]> <$3> <$4 $5> <$6> +R <$+.$+> <$+> <$- $-> <$*> $@ $>D <$2> <$3> <$4 $5> <$6> +R <$+> <$+> <$- $-> <$*> $@ <$2> <$5> +R<$* > <$+> <$+> <$- $-> <$*> $@ <> <$6> +R<$*> <$+> <$+> <$- $-> <$*> $@ <$1> <$6> + +###################################################################### +### A: LookUpAddress -- search for host address in access database +### +### Parameters: +### <$1> -- key (dot quadded host address) +### <$2> -- default (what to return if not found in db) +### <$3> -- mark (must be <(!|+) single-token>) +### ! does lookup only with tag +### + does lookup with and without tag +### <$4> -- passthru (additional data passed through) +###################################################################### + +SA +R<$+> <$+> <$- $-> <$*> $: < $(access $4:$1 $: ? $) > <$1> <$2> <$3 $4> <$5> +R <$+> <$+> <+ $-> <$*> $: < $(access $1 $: ? $) > <$1> <$2> <+ $3> <$4> +R <$+> <$+> <$- $-> <$*> $@ <$2> <$5> +R <$+::$-> <$+> <$- $-> <$*> $@ $>A <$1> <$3> <$4 $5> <$6> +R <$+:$-> <$+> <$- $-> <$*> $@ $>A <$1> <$3> <$4 $5> <$6> +R <$+.$-> <$+> <$- $-> <$*> $@ $>A <$1> <$3> <$4 $5> <$6> +R <$+> <$+> <$- $-> <$*> $@ <$2> <$5> +R<$* > <$+> <$+> <$- $-> <$*> $@ <> <$6> +R<$*> <$+> <$+> <$- $-> <$*> $@ <$1> <$6> + +###################################################################### +### CanonAddr -- Convert an address into a standard form for +### relay checking. Route address syntax is +### crudely converted into a %-hack address. +### +### Parameters: +### $1 -- full recipient address +### +### Returns: +### parsed address, not in source route form +###################################################################### + +SCanonAddr +R$* $: $>Parse0 $>canonify $1 make domain canonical + + +###################################################################### +### ParseRecipient -- Strip off hosts in $=R as well as possibly +### $* $=m or the access database. +### Check user portion for host separators. +### +### Parameters: +### $1 -- full recipient address +### +### Returns: +### parsed, non-local-relaying address +###################################################################### + +SParseRecipient +R$* $: $>CanonAddr $1 +R $* < @ $* . > $1 < @ $2 > strip trailing dots +R $- < @ $* > $: $(dequote $1 $) < @ $2 > dequote local part + +# if no $=O character, no host in the user portion, we are done +R $* $=O $* < @ $* > $: $1 $2 $3 < @ $4> +R $* $@ $1 + + +R $* < @ $* $=R > $: $1 < @ $2 $3 > +R $* < @ $+ > $: $>D <$2> <+ To> <$1 < @ $2 >> +R<$+> <$+> $: <$1> $2 + + + +R $* < @ $* > $@ $>ParseRecipient $1 +R<$+> $* $@ $2 + + +###################################################################### +### check_relay -- check hostname/address on SMTP startup +###################################################################### + +Scheck_relay +R$* $: $>"RateControl" dummy +R$* $: $>"ConnControl" dummy + +SLocal_check_relay +Scheckrelay +R$* $: $1 $| $>"Local_check_relay" $1 +R$* $| $* $| $#$* $#$3 +R$* $| $* $| $* $@ $>"Basic_check_relay" $1 $| $2 + +SBasic_check_relay +# check for deferred delivery mode +R$* $: < $&{deliveryMode} > $1 +R< d > $* $@ deferred +R< $* > $* $: $2 + +R$+ $| $+ $: $>D < $1 > <+ Connect> < $2 > +R $| $+ $: $>A < $1 > <+ Connect> <> empty client_name +R <$+> $: $>A < $1 > <+ Connect> <> no: another lookup +R <$*> $: OK found nothing +R<$={Accept}> <$*> $@ $1 return value of lookup +R <$*> $#error $@ 5.7.1 $: "550 Access denied" +R <$*> $#discard $: discard +R <$*> $#error $@ quarantine $: $1 +R <$*> $#error $@ $1.$2.$3 $: $4 +R <$*> $#error $: $1 +R<$* > <$*> $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<$+> <$*> $#error $: $1 + + + + +###################################################################### +### check_mail -- check SMTP `MAIL FROM:' command argument +###################################################################### + +SLocal_check_mail +Scheckmail +R$* $: $1 $| $>"Local_check_mail" $1 +R$* $| $#$* $#$2 +R$* $| $* $@ $>"Basic_check_mail" $1 + +SBasic_check_mail +# check for deferred delivery mode +R$* $: < $&{deliveryMode} > $1 +R< d > $* $@ deferred +R< $* > $* $: $2 + +# authenticated? +R$* $: $1 $| $>"tls_client" $&{verify} $| MAIL +R$* $| $#$+ $#$2 +R$* $| $* $: $1 + +R<> $@ we MUST accept <> (RFC 1123) +R$+ $: $1 +R<$+> $: <@> <$1> +R$+ $: <@> <$1> +R$* $: $&{daemon_flags} $| $1 +R$* f $* $| <@> < $* @ $- > $: < ? $&{client_name} > < $3 @ $4 > +R$* u $* $| <@> < $* > $: < $3 > +R$* $| $* $: $2 +# handle case of @localhost on address +R<@> < $* @ localhost > $: < ? $&{client_name} > < $1 @ localhost > +R<@> < $* @ [127.0.0.1] > + $: < ? $&{client_name} > < $1 @ [127.0.0.1] > +R<@> < $* @ [IPv6:0:0:0:0:0:0:0:1] > + $: < ? $&{client_name} > < $1 @ [IPv6:0:0:0:0:0:0:0:1] > +R<@> < $* @ [IPv6:::1] > + $: < ? $&{client_name} > < $1 @ [IPv6:::1] > +R<@> < $* @ localhost.$m > + $: < ? $&{client_name} > < $1 @ localhost.$m > +R<@> < $* @ localhost.UUCP > + $: < ? $&{client_name} > < $1 @ localhost.UUCP > +R<@> $* $: $1 no localhost as domain +R $* $: $2 local client: ok +R <$+> $#error $@ 5.5.4 $: "553 Real domain name required for sender address" +R $* $: $1 +R$* $: $>CanonAddr $1 canonify sender address and mark it +R $* < @ $+ . > $1 < @ $2 > strip trailing dots +# handle non-DNS hostnames (*.bitnet, *.decnet, *.uucp, etc) +R $* < @ $* $=P > $: $1 < @ $2 $3 > +R $* < @ $j > $: $1 < @ $j > +R $* < @ $+ > $: $) > $1 < @ $2 > +R> $* < @ $+ > + $: <$2> $3 < @ $4 > + +# check sender address: user@address, user@, address +R<$+> $+ < @ $* > $: @<$1> <$2 < @ $3 >> $| +R<$+> $+ $: @<$1> <$2> $| +R@ <$+> <$*> $| <$+> $: <@> <$1> <$2> $| $>SearchList <+ From> $| <$3> <> +R<@> <$+> <$*> $| <$*> $: <$3> <$1> <$2> reverse result +# retransform for further use +R <$+> <$*> $: <$1> $2 no match +R<$+> <$+> <$*> $: <$1> $3 relevant result, keep it + +# handle case of no @domain on address +R $* $: $&{daemon_flags} $| $1 +R$* u $* $| $* $: $3 +R$* $| $* $: $2 +R $* $: < ? $&{client_addr} > $1 +R $* $@ ...local unqualed ok +R $* $#error $@ 5.5.4 $: "553 Domain name required for sender address " $&f + ...remote is not +# check results +R $* $: @ $1 mark address: nothing known about it +R<$={ResOk}> $* $: @ $2 domain ok +R $* $#error $@ 4.1.8 $: "451 Domain of sender address " $&f " does not resolve" +R $* $#error $@ 5.1.8 $: "553 Domain of sender address " $&f " does not exist" +R<$={Accept}> $* $# $1 accept from access map +R $* $#discard $: discard +R $* $#error $@ quarantine $: $1 +R $* $#error $@ 5.7.1 $: "550 Access denied" +R $* $#error $@ $1.$2.$3 $: $4 +R $* $#error $: $1 +R<> $* $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<$+> $* $#error $: $1 error from access db + + + +###################################################################### +### check_rcpt -- check SMTP `RCPT TO:' command argument +###################################################################### + +SLocal_check_rcpt +Scheckrcpt +R$* $: $1 $| $>"Local_check_rcpt" $1 +R$* $| $#$* $#$2 +R$* $| $* $@ $>"Basic_check_rcpt" $1 + +SBasic_check_rcpt +# empty address? +R<> $#error $@ nouser $: "553 User address required" +R$@ $#error $@ nouser $: "553 User address required" +# check for deferred delivery mode +R$* $: < $&{deliveryMode} > $1 +R< d > $* $@ deferred +R< $* > $* $: $2 + + +###################################################################### +R$* $: $1 $| @ $>"Rcpt_ok" $1 +R$* $| @ $#TEMP $+ $: $1 $| T $2 +R$* $| @ $#$* $#$2 +R$* $| @ RELAY $@ RELAY +R$* $| @ $* $: O $| $>"Relay_ok" $1 +R$* $| T $+ $: T $2 $| $>"Relay_ok" $1 +R$* $| $#TEMP $+ $#error $2 +R$* $| $#$* $#$2 +R$* $| RELAY $@ RELAY +R T $+ $| $* $#error $1 +# anything else is bogus +R$* $#error $@ 5.7.1 $: "550 Relaying denied" + + +###################################################################### +### Rcpt_ok: is the recipient ok? +###################################################################### +SRcpt_ok +R$* $: $>ParseRecipient $1 strip relayable hosts + + + + +# authenticated via TLS? +R$* $: $1 $| $>RelayTLS client authenticated? +R$* $| $# $+ $# $2 error/ok? +R$* $| $* $: $1 no + +R$* $: $1 $| $>"Local_Relay_Auth" $&{auth_type} +R$* $| $# $* $# $2 +R$* $| NO $: $1 +R$* $| $* $: $1 $| $&{auth_type} +R$* $| $: $1 +R$* $| $={TrustAuthMech} $# RELAY +R$* $| $* $: $1 +# anything terminating locally is ok +R$+ < @ $=w > $@ RELAY +R$+ < @ $* $=R > $@ RELAY +R$+ < @ $+ > $: $>D <$2> <+ To> <$1 < @ $2 >> +R $* $@ RELAY +R<$* > $* $#TEMP $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<$*> <$*> $: $2 + + + +# check for local user (i.e. unqualified address) +R$* $: $1 +R $* < @ $+ > $: $1 < @ $2 > +# local user is ok +R $+ $@ RELAY +R<$+> $* $: $2 + +###################################################################### +### Relay_ok: is the relay/sender ok? +###################################################################### +SRelay_ok +# anything originating locally is ok +# check IP address +R$* $: $&{client_addr} +R$@ $@ RELAY originated locally +R0 $@ RELAY originated locally +R127.0.0.1 $@ RELAY originated locally +RIPv6:0:0:0:0:0:0:0:1 $@ RELAY originated locally +RIPv6:::1 $@ RELAY originated locally +R$=R $* $@ RELAY relayable IP address +R$* $: $>A <$1> <+ Connect> <$1> +R $* $@ RELAY relayable IP address + +R<> $* $#TEMP $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<$*> <$*> $: $2 +R$* $: [ $1 ] put brackets around it... +R$=w $@ RELAY ... and see if it is local + + +# check client name: first: did it resolve? +R$* $: < $&{client_resolve} > +R $#TEMP $@ 4.4.0 $: "450 Relaying temporarily denied. Cannot resolve PTR record for " $&{client_addr} +R $#error $@ 5.7.1 $: "550 Relaying denied. IP name possibly forged " $&{client_name} +R $#error $@ 5.7.1 $: "550 Relaying denied. IP name lookup failed " $&{client_name} +R$* $: <@> $&{client_name} +# pass to name server to make hostname canonical +R<@> $* $=P $: $1 $2 +R<@> $+ $: $[ $1 $] +R$* . $1 strip trailing dots +R $=w $@ RELAY +R $* $=R $@ RELAY +R $* $: $>D <$1> <+ Connect> <$1> +R $* $@ RELAY +R<$* > $* $#TEMP $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<$*> <$*> $: $2 + +# turn a canonical address in the form user<@domain> +# qualify unqual. addresses with $j +SFullAddr +R$* <@ $+ . > $1 <@ $2 > +R$* <@ $* > $@ $1 <@ $2 > +R$+ $@ $1 <@ $j > + +SDelay_TLS_Clt +# authenticated? +R$* $: $1 $| $>"tls_client" $&{verify} $| MAIL +R$* $| $#$+ $#$2 +R$* $| $* $# $1 +R$* $# $1 + +SDelay_TLS_Clt2 +# authenticated? +R$* $: $1 $| $>"tls_client" $&{verify} $| MAIL +R$* $| $#$+ $#$2 +R$* $| $* $@ $1 +R$* $@ $1 + +# call all necessary rulesets +Scheck_rcpt +# R$@ $#error $@ 5.1.3 $: "553 Recipient address required" + +R$+ $: $1 $| $>checkrcpt $1 +R$+ $| $#error $* $#error $2 +R$+ $| $#discard $* $#discard $2 +R$+ $| $#$* $@ $>"Delay_TLS_Clt" $2 +R$+ $| $* $: $>FullAddr $>CanonAddr $1 +R $+ < @ $=w > $: <> $1 < @ $2 > $| +R $+ < @ $* > $: <> $1 < @ $2 > $| +# lookup the addresses only with Spam tag +R<> $* $| <$+> $: <@> $1 $| $>SearchList $| <$2> <> +R<@> $* $| $* $: $2 $1 reverse result +# is the recipient a spam friend? +R $+ $@ $>"Delay_TLS_Clt2" SPAMFRIEND +R<$*> $+ $: $2 + +R$* $: $1 $| $>checkmail $&{mail_from} +R$* $| $#$* $#$2 +R$* $| $* $: $1 $| $>checkrelay $&{client_name} $| $&{client_addr} +R$* $| $#$* $#$2 +R$* $| $* $: $1 + + + +###################################################################### +### F: LookUpFull -- search for an entry in access database +### +### lookup of full key (which should be an address) and +### variations if +detail exists: +* and without +detail +### +### Parameters: +### <$1> -- key +### <$2> -- default (what to return if not found in db) +### <$3> -- mark (must be <(!|+) single-token>) +### ! does lookup only with tag +### + does lookup with and without tag +### <$4> -- passthru (additional data passed unchanged through) +###################################################################### + +SF +R<$+> <$*> <$- $-> <$*> $: <$(access $4:$1 $: ? $)> <$1> <$2> <$3 $4> <$5> +R <$+> <$*> <+ $-> <$*> $: <$(access $1 $: ? $)> <$1> <$2> <+ $3> <$4> +R <$+ + $* @ $+> <$*> <$- $-> <$*> + $: <$(access $6:$1+*@$3 $: ? $)> <$1+$2@$3> <$4> <$5 $6> <$7> +R <$+ + $* @ $+> <$*> <+ $-> <$*> + $: <$(access $1+*@$3 $: ? $)> <$1+$2@$3> <$4> <+ $5> <$6> +R <$+ + $* @ $+> <$*> <$- $-> <$*> + $: <$(access $6:$1@$3 $: ? $)> <$1+$2@$3> <$4> <$5 $6> <$7> +R <$+ + $* @ $+> <$*> <+ $-> <$*> + $: <$(access $1@$3 $: ? $)> <$1+$2@$3> <$4> <+ $5> <$6> +R <$+> <$*> <$- $-> <$*> $@ <$2> <$5> +R<$+ > <$*> <$- $-> <$*> $@ <> <$5> +R<$+> <$*> <$- $-> <$*> $@ <$1> <$5> + +###################################################################### +### E: LookUpExact -- search for an entry in access database +### +### Parameters: +### <$1> -- key +### <$2> -- default (what to return if not found in db) +### <$3> -- mark (must be <(!|+) single-token>) +### ! does lookup only with tag +### + does lookup with and without tag +### <$4> -- passthru (additional data passed unchanged through) +###################################################################### + +SE +R<$*> <$*> <$- $-> <$*> $: <$(access $4:$1 $: ? $)> <$1> <$2> <$3 $4> <$5> +R <$+> <$*> <+ $-> <$*> $: <$(access $1 $: ? $)> <$1> <$2> <+ $3> <$4> +R <$+> <$*> <$- $-> <$*> $@ <$2> <$5> +R<$+ > <$*> <$- $-> <$*> $@ <> <$5> +R<$+> <$*> <$- $-> <$*> $@ <$1> <$5> + +###################################################################### +### U: LookUpUser -- search for an entry in access database +### +### lookup of key (which should be a local part) and +### variations if +detail exists: +* and without +detail +### +### Parameters: +### <$1> -- key (user@) +### <$2> -- default (what to return if not found in db) +### <$3> -- mark (must be <(!|+) single-token>) +### ! does lookup only with tag +### + does lookup with and without tag +### <$4> -- passthru (additional data passed unchanged through) +###################################################################### + +SU +R<$+> <$*> <$- $-> <$*> $: <$(access $4:$1 $: ? $)> <$1> <$2> <$3 $4> <$5> +R <$+> <$*> <+ $-> <$*> $: <$(access $1 $: ? $)> <$1> <$2> <+ $3> <$4> +R <$+ + $* @> <$*> <$- $-> <$*> + $: <$(access $5:$1+*@ $: ? $)> <$1+$2@> <$3> <$4 $5> <$6> +R <$+ + $* @> <$*> <+ $-> <$*> + $: <$(access $1+*@ $: ? $)> <$1+$2@> <$3> <+ $4> <$5> +R <$+ + $* @> <$*> <$- $-> <$*> + $: <$(access $5:$1@ $: ? $)> <$1+$2@> <$3> <$4 $5> <$6> +R <$+ + $* @> <$*> <+ $-> <$*> + $: <$(access $1@ $: ? $)> <$1+$2@> <$3> <+ $4> <$5> +R <$+> <$*> <$- $-> <$*> $@ <$2> <$5> +R<$+ > <$*> <$- $-> <$*> $@ <> <$5> +R<$+> <$*> <$- $-> <$*> $@ <$1> <$5> + +###################################################################### +### SearchList: search a list of items in the access map +### Parameters: +### $| ... <> +### where "exact" is either "+" or "!": +### <+ TAG> lookup with and w/o tag +### lookup with tag +### possible values for "mark" are: +### D: recursive host lookup (LookUpDomain) +### E: exact lookup, no modifications +### F: full lookup, try user+ext@domain and user@domain +### U: user lookup, try user+ext and user (input must have trailing @) +### return: or (not found) +###################################################################### + +# class with valid marks for SearchList +C{Src}E F D U A +SSearchList +# just call the ruleset with the name of the tag... nice trick... +R<$+> $| <$={Src}:$*> <$*> $: <$1> $| <$4> $| $>$2 <$3> <$1> <> +R<$+> $| <> $| <> $@ +R<$+> $| <$+> $| <> $@ $>SearchList <$1> $| <$2> +R<$+> $| <$*> $| <$+> <> $@ <$3> +R<$+> $| <$+> $@ <$2> + + +###################################################################### +### trust_auth: is user trusted to authenticate as someone else? +### +### Parameters: +### $1: AUTH= parameter from MAIL command +###################################################################### + +SLocal_trust_auth +Strust_auth +R$* $: $&{auth_type} $| $1 +# required by RFC 2554 section 4. +R$@ $| $* $#error $@ 5.7.1 $: "550 not authenticated" +R$* $| $&{auth_authen} $@ identical +R$* $| <$&{auth_authen}> $@ identical +R$* $| $* $: $1 $| $>"Local_trust_auth" $2 +R$* $| $#$* $#$2 +R$* $#error $@ 5.7.1 $: "550 " $&{auth_authen} " not allowed to act as " $&{auth_author} + +###################################################################### +### Relay_Auth: allow relaying based on authentication? +### +### Parameters: +### $1: ${auth_type} +###################################################################### +SLocal_Relay_Auth + +###################################################################### +### srv_features: which features to offer to a client? +### (done in server) +###################################################################### +Ssrv_features +R$* $: $>D <$&{client_name}> <> +R$* $: $>A <$&{client_addr}> <> +R$* $: <$(access "Srv_Features": $: ? $)> +R$* $@ OK +R<$* >$* $#temp +R<$+>$* $# $1 + +###################################################################### +### try_tls: try to use STARTTLS? +### (done in client) +###################################################################### +Stry_tls +R$* $: $>D <$&{server_name}> <> +R$* $: $>A <$&{server_addr}> <> +R$* $: <$(access "Try_TLS": $: ? $)> +R$* $@ OK +R<$* >$* $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R$* $#error $@ 5.7.1 $: "550 do not try TLS with " $&{server_name} " ["$&{server_addr}"]" + +###################################################################### +### tls_rcpt: is connection with server "good" enough? +### (done in client, per recipient) +### +### Parameters: +### $1: recipient +###################################################################### +Stls_rcpt +R$* $: $(macro {TLS_Name} $@ $&{server_name} $) $1 +R$+ $: $>CanonAddr $1 +R $+ < @ $+ . > $1 <@ $2 > +R $+ < @ $+ > $: $1 <@ $2 > $| +R $+ $: $1 $| +R$* $| $+ $: $1 $| $>SearchList $| $2 <> +R$* $| $@ OK +R$* $| <$* > $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R$* $| <$+> $@ $>"TLS_connection" $&{verify} $| <$2> + +###################################################################### +### tls_client: is connection with client "good" enough? +### (done in server) +### +### Parameters: +### ${verify} $| (MAIL|STARTTLS) +###################################################################### +Stls_client +R$* $: $(macro {TLS_Name} $@ $&{client_name} $) $1 +R$* $| $* $: $1 $| $>D <$&{client_name}> <> +R$* $| $* $: $1 $| $>A <$&{client_addr}> <> +R$* $| $* $: $1 $| <$(access "TLS_Clt": $: ? $)> +R$* $| <$* > $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R$* $@ $>"TLS_connection" $1 + +###################################################################### +### tls_server: is connection with server "good" enough? +### (done in client) +### +### Parameter: +### ${verify} +###################################################################### +Stls_server +R$* $: $(macro {TLS_Name} $@ $&{server_name} $) $1 +R$* $: $1 $| $>D <$&{server_name}> <> +R$* $| $* $: $1 $| $>A <$&{server_addr}> <> +R$* $| $* $: $1 $| <$(access "TLS_Srv": $: ? $)> +R$* $| <$* > $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R$* $@ $>"TLS_connection" $1 + +###################################################################### +### TLS_connection: is TLS connection "good" enough? +### +### Parameters: +### ${verify} $| [<>] +### Requirement: RHS from access map, may be ? for none. +###################################################################### +STLS_connection +R$* $| <$*>$* $: $1 $| <$2> +# create the appropriate error codes +R$* $| $: $1 $| <503:5.7.0> <$2 $3> +R$* $| $: $1 $| <403:4.7.0> <$2 $3> +R$* $| <$={Tls} $*> $: $1 $| <403:4.7.0> <$2 $3> +# deal with TLS handshake failures: abort +RSOFTWARE $| <$-:$+> $* $#error $@ $2 $: $1 " TLS handshake failed." +RSOFTWARE $| $* $#error $@ 4.7.0 $: "403 TLS handshake failed." +# deal with TLS protocol errors: abort +RPROTOCOL $| <$-:$+> $* $#error $@ $2 $: $1 " STARTTLS failed." +RPROTOCOL $| $* $#error $@ 4.7.0 $: "403 STARTTLS failed." +R$* $| <$*> $: <$2> <> $1 +R$* $| <$*> $: <$2> <$3> $1 +R$* $| <$*> <$={Tls}:$->$* $: <$2> <$3:$4> <> $1 +R$* $| <$*> <$={Tls}:$- + $+>$* $: <$2> <$3:$4> <$5> $1 +R$* $| $* $@ OK +# authentication required: give appropriate error +# other side did authenticate (via STARTTLS) +R<$*> <> OK $@ OK +R<$*> <$+> OK $: <$1> <$2> +R<$*> <$*> OK $: <$1> <$3> +R<$*> <$*> $* $: <$1> <$3> +R<$-:$+> <$*> $#error $@ $2 $: $1 " authentication required" +R<$-:$+> <$*> FAIL $#error $@ $2 $: $1 " authentication failed" +R<$-:$+> <$*> NO $#error $@ $2 $: $1 " not authenticated" +R<$-:$+> <$*> NOT $#error $@ $2 $: $1 " no authentication requested" +R<$-:$+> <$*> NONE $#error $@ $2 $: $1 " other side does not support STARTTLS" +R<$-:$+> <$*> $+ $#error $@ $2 $: $1 " authentication failure " $4 +R<$*> <$*> $: <$1> <$3> $>max $&{cipher_bits} : $&{auth_ssf} +R<$*> <$*> $- $: <$1> <$2:$4> <$3> $(arith l $@ $4 $@ $2 $) +R<$-:$+><$-:$-> <$*> TRUE $#error $@ $2 $: $1 " encryption too weak " $4 " less than " $3 +R<$-:$+><$-:$-> <$*> $* $: <$1:$2 ++ $5> +R<$-:$+ ++ > $@ OK +R<$-:$+ ++ $+ > $: <$1:$2> <$3> +R<$-:$+> < $+ ++ $+ > <$1:$2> <$3> <$4> +R<$-:$+> $+ $@ $>"TLS_req" $3 $| <$1:$2> + +###################################################################### +### TLS_req: check additional TLS requirements +### +### Parameters: [ ] $| <$-:$+> +### $-: SMTP reply code +### $+: Enhanced Status Code +###################################################################### +STLS_req +R $| $+ $@ OK +R $* $| <$+> $: $1 $| <$2> +R $* $| <$+> $@ $>"TLS_req" $1 $| <$2> +R $* $| <$-:$+> $#error $@ $4 $: $3 " CN " $&{cn_subject} " does not match " $1 +R $* $| <$+> $@ $>"TLS_req" $1 $| <$2> +R $* $| <$-:$+> $#error $@ $4 $: $3 " Cert Subject " $&{cert_subject} " does not match " $1 +R $* $| <$+> $@ $>"TLS_req" $1 $| <$2> +R $* $| <$-:$+> $#error $@ $4 $: $3 " Cert Issuer " $&{cert_issuer} " does not match " $1 +ROK $@ OK + +###################################################################### +### max: return the maximum of two values separated by : +### +### Parameters: [$-]:[$-] +###################################################################### +Smax +R: $: 0 +R:$- $: $1 +R$-: $: $1 +R$-:$- $: $(arith l $@ $1 $@ $2 $) : $1 : $2 +RTRUE:$-:$- $: $2 +R$-:$-:$- $: $2 + + + + +###################################################################### +### RelayTLS: allow relaying based on TLS authentication +### +### Parameters: +### none +###################################################################### +SRelayTLS +# authenticated? +R$* $: $&{verify} +R OK $: OK authenticated: continue +R $* $@ NO not authenticated +R$* $: $&{cert_issuer} +R$+ $: $(access CERTISSUER:$1 $) +RRELAY $# RELAY +RSUBJECT $: <@> $&{cert_subject} +R<@> $+ $: <@> $(access CERTSUBJECT:$1 $) +R<@> RELAY $# RELAY +R$* $: NO + +###################################################################### +### authinfo: lookup authinfo in the access map +### +### Parameters: +### $1: {server_name} +### $2: {server_addr} +###################################################################### +Sauthinfo +R$* $: $1 $| $>D <$&{server_name}> <> +R$* $| $* $: $1 $| $>A <$&{server_addr}> <> +R$* $| $* $: $1 $| <$(access AuthInfo: $: ? $)> <> +R$* $| $* $@ no no authinfo available +R$* $| <$*> <> $# $2 + +###################################################################### +### RateControl: +### Parameters: ignored +### return: $#error or OK +###################################################################### +SRateControl +R$* $: +R$+ $: $>SearchList $| $1 <> +R $@ OK +R<$* > $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<0> $@ OK no limit +R<$+> $: <$1> $| $(arith l $@ $1 $@ $&{client_rate} $) +R<$+> $| TRUE $#error $@ 4.3.2 $: 421 Connection rate limit exceeded. + + +###################################################################### +### ConnControl: +### Parameters: ignored +### return: $#error or OK +###################################################################### +SConnControl +R$* $: +R$+ $: $>SearchList $| $1 <> +R $@ OK +R<$* > $#error $@ 4.3.0 $: "451 Temporary system failure. Please try again later." +R<0> $@ OK no limit +R<$+> $: <$1> $| $(arith l $@ $1 $@ $&{client_connections} $) +R<$+> $| TRUE $#error $@ 4.3.2 $: 421 Too many open connections. + + + + + +###################################################################### +### greet_pause: lookup pause time before 220 greeting +### +### Parameters: +### $1: {client_name} +### $2: {client_addr} +###################################################################### +SLocal_greet_pause +Sgreet_pause +R$* $: <$1> $| $>"Local_greet_pause" $1 +R<$*> $| $#$* $#$2 +R<$*> $| $* $: $1 +R$+ $| $+ $: $>D < $1 > < $2 > +R $| $+ $: $>A < $1 > <> empty client_name +R <$+> $: $>A < $1 > <> no: another lookup +R <$*> $# 1000 +R<$* > <$*> $@ +R<$+> <$*> $# $1 +# + +###################################################################### +###################################################################### +##### +##### MAIL FILTER DEFINITIONS +##### +###################################################################### +###################################################################### + + +Xmilter, S=inet:%{MILTER_PORT}@localhost, F=R + +###################################################################### +###################################################################### +##### +##### MAILER DEFINITIONS +##### +###################################################################### +###################################################################### + + + +################################################## +### Local and Program Mailer specification ### +################################################## + +##### $Id: local.m4,v 8.60 2013-11-22 20:51:14 ca Exp $ ##### + +# +# Envelope sender rewriting +# +SEnvFromL +R<@> $n errors to mailer-daemon +R@ <@ $*> $n temporarily bypass Sun bogosity +R$+ $: $>AddDomain $1 add local domain if needed +R$* $: $>MasqEnv $1 do masquerading + +# +# Envelope recipient rewriting +# +SEnvToL +R$+ < @ $* > $: $1 strip host part +R$+ + $* $: < $&{addr_type} > $1 + $2 mark with addr type +R $+ + $* $: $1 remove +detail for sender +R< $* > $+ $: $2 else remove mark + +# +# Header sender rewriting +# +SHdrFromL +R<@> $n errors to mailer-daemon +R@ <@ $*> $n temporarily bypass Sun bogosity +R$+ $: $>AddDomain $1 add local domain if needed +R$* $: $>MasqHdr $1 do masquerading + +# +# Header recipient rewriting +# +SHdrToL +R$+ $: $>AddDomain $1 add local domain if needed +R$* < @ *LOCAL* > $* $: $1 < @ $j . > $2 + +# +# Common code to add local domain name (only if always-add-domain) +# +SAddDomain + +Mlocal, P=/usr/sbin/sensible-mda, F=lsDFMAw5:/|@qPn9S, S=EnvFromL/HdrFromL, R=EnvToL/HdrToL, + T=DNS/RFC822/X-Unix, + A=sensible-mda $g $u $h ${client_addr} +Mprog, P=/bin/sh, F=lsDFMoqeu9, S=EnvFromL/HdrFromL, R=EnvToL/HdrToL, D=$z:/, + T=X-Unix/X-Unix/X-Unix, + A=sh -c $u + +##################################### +### SMTP Mailer specification ### +##################################### + +##### $Id: smtp.m4,v 8.66 2013-11-22 20:51:14 ca Exp $ ##### + +# +# common sender and masquerading recipient rewriting +# +SMasqSMTP +R$* < @ $* > $* $@ $1 < @ $2 > $3 already fully qualified +R$+ $@ $1 < @ *LOCAL* > add local qualification + +# +# convert pseudo-domain addresses to real domain addresses +# +SPseudoToReal + +# pass s through +R< @ $+ > $* $@ < @ $1 > $2 resolve + +# output fake domains as user%fake@relay + +# do UUCP heuristics; note that these are shared with UUCP mailers +R$+ < @ $+ .UUCP. > $: < $2 ! > $1 convert to UUCP form +R$+ < @ $* > $* $@ $1 < @ $2 > $3 not UUCP form + +# leave these in .UUCP form to avoid further tampering +R< $&h ! > $- ! $+ $@ $2 < @ $1 .UUCP. > +R< $&h ! > $-.$+ ! $+ $@ $3 < @ $1.$2 > +R< $&h ! > $+ $@ $1 < @ $&h .UUCP. > +R< $+ ! > $+ $: $1 ! $2 < @ $Y > use UUCP_RELAY +R$+ < @ $~[ $* : $+ > $@ $1 < @ $4 > strip mailer: part +R$+ < @ > $: $1 < @ *LOCAL* > if no UUCP_RELAY + + +# +# envelope sender rewriting +# +SEnvFromSMTP +R$+ $: $>PseudoToReal $1 sender/recipient common +R$* :; <@> $@ list:; special case +R$* $: $>MasqSMTP $1 qualify unqual'ed names +R$+ $: $>MasqEnv $1 do masquerading + + +# +# envelope recipient rewriting -- +# also header recipient if not masquerading recipients +# +SEnvToSMTP +R$+ $: $>PseudoToReal $1 sender/recipient common +R$+ $: $>MasqSMTP $1 qualify unqual'ed names +R$* < @ *LOCAL* > $* $: $1 < @ $j . > $2 + +# +# header sender and masquerading header recipient rewriting +# +SHdrFromSMTP +R$+ $: $>PseudoToReal $1 sender/recipient common +R:; <@> $@ list:; special case + +# do special header rewriting +R$* <@> $* $@ $1 <@> $2 pass null host through +R< @ $* > $* $@ < @ $1 > $2 pass route-addr through +R$* $: $>MasqSMTP $1 qualify unqual'ed names +R$+ $: $>MasqHdr $1 do masquerading + + +# +# relay mailer header masquerading recipient rewriting +# +SMasqRelay +R$+ $: $>MasqSMTP $1 +R$+ $: $>MasqHdr $1 + +Msmtp, P=[IPC], F=mDFMuX, S=EnvFromSMTP/HdrFromSMTP, R=EnvToSMTP, E=\r\n, L=990, + T=DNS/RFC822/SMTP, + A=TCP [127.0.0.1] %{RECEIVER_PORT} +Mesmtp, P=[IPC], F=mDFMuXa, S=EnvFromSMTP/HdrFromSMTP, R=EnvToSMTP, E=\r\n, L=990, + T=DNS/RFC822/SMTP, + A=TCP [127.0.0.1] %{RECEIVER_PORT} +Msmtp8, P=[IPC], F=mDFMuX8, S=EnvFromSMTP/HdrFromSMTP, R=EnvToSMTP, E=\r\n, L=990, + T=DNS/RFC822/SMTP, + A=TCP [127.0.0.1] %{RECEIVER_PORT} +Mdsmtp, P=[IPC], F=mDFMuXa%, S=EnvFromSMTP/HdrFromSMTP, R=EnvToSMTP, E=\r\n, L=990, + T=DNS/RFC822/SMTP, + A=TCP [127.0.0.1] %{RECEIVER_PORT} +Mrelay, P=[IPC], F=mDFMuXa8, S=EnvFromSMTP/HdrFromSMTP, R=MasqSMTP, E=\r\n, L=2040, + T=DNS/RFC822/SMTP, + A=TCP [127.0.0.1] %{RECEIVER_PORT} + + +### /etc/mail/sendmail.mc ### +# divert(-1)dnl +# #----------------------------------------------------------------------------- +# # $Sendmail: debproto.mc,v 8.15.2 2021-12-09 00:18:01 cowboy Exp $ +# # +# # Copyright (c) 1998-2010 Richard Nelson. All Rights Reserved. +# # +# # cf/debian/sendmail.mc. Generated from sendmail.mc.in by configure. +# # +# # sendmail.mc prototype config file for building Sendmail 8.15.2 +# # +# # Note: the .in file supports 8.15.0 - 9.0.0, but the generated +# # file is customized to the version noted above. +# # +# # This file is used to configure Sendmail for use with Debian systems. +# # +# # If you modify this file, you will have to regenerate /etc/mail/sendmail.cf +# # by running this file through the m4 preprocessor via one of the following: +# # * make (or make -C /etc/mail) +# # * sendmailconfig +# # * m4 /etc/mail/sendmail.mc > /etc/mail/sendmail.cf +# # The first two options are preferred as they will also update other files +# # that depend upon the contents of this file. +# # +# # The best documentation for this .mc file is: +# # /usr/share/doc/sendmail-doc/cf.README.gz +# # +# #----------------------------------------------------------------------------- +# divert(0)dnl +# # +# # Copyright (c) 1998-2005 Richard Nelson. All Rights Reserved. +# # +# # This file is used to configure Sendmail for use with Debian systems. +# # +# define(`_USE_ETC_MAIL_')dnl +# include(`/usr/share/sendmail/cf/m4/cf.m4')dnl +# VERSIONID(`$Id: sendmail.mc, v 8.15.2-22ubuntu3 2021-12-09 00:18:01 cowboy Exp $') +# OSTYPE(`debian')dnl +# DOMAIN(`debian-mta')dnl +# dnl # Items controlled by /etc/mail/sendmail.conf - DO NOT TOUCH HERE +# undefine(`confHOST_STATUS_DIRECTORY')dnl #DAEMON_HOSTSTATS= +# dnl # Items controlled by /etc/mail/sendmail.conf - DO NOT TOUCH HERE +# dnl # +# dnl # General defines +# dnl # +# dnl # SAFE_FILE_ENV: [undefined] If set, sendmail will do a chroot() +# dnl # into this directory before writing files. +# dnl # If *all* your user accounts are under /home then use that +# dnl # instead - it will prevent any writes outside of /home ! +# dnl # define(`confSAFE_FILE_ENV', `')dnl +# dnl # +# dnl # Daemon options - restrict to servicing LOCALHOST ONLY !!! +# dnl # Remove `, Addr=' clauses to receive from any interface +# dnl # If you want to support IPv6, switch the commented/uncommentd lines +# dnl # +# FEATURE(`no_default_msa')dnl +# dnl DAEMON_OPTIONS(`Family=inet6, Name=MTA-v6, Port=smtp, Addr=::1')dnl +# DAEMON_OPTIONS(`Family=inet, Name=MTA-v4, Port=smtp, Addr=127.0.0.1')dnl +# dnl DAEMON_OPTIONS(`Family=inet6, Name=MSP-v6, Port=submission, M=Ea, Addr=::1')dnl +# DAEMON_OPTIONS(`Family=inet, Name=MSP-v4, Port=submission, M=Ea, Addr=127.0.0.1')dnl +# dnl # +# dnl # Be somewhat anal in what we allow +# define(`confPRIVACY_FLAGS',dnl +# `needmailhelo,needexpnhelo,needvrfyhelo,restrictqrun,restrictexpand,nobodyreturn,authwarnings')dnl +# dnl # +# dnl # Define connection throttling and window length +# define(`confCONNECTION_RATE_THROTTLE', `15')dnl +# define(`confCONNECTION_RATE_WINDOW_SIZE',`10m')dnl +# dnl # +# dnl # Features +# dnl # +# dnl # use /etc/mail/local-host-names +# FEATURE(`use_cw_file')dnl +# dnl # +# dnl # The access db is the basis for most of sendmail's checking +# FEATURE(`access_db', , `skip')dnl +# dnl # +# dnl # The greet_pause feature stops some automail bots - but check the +# dnl # provided access db for details on excluding localhosts... +# FEATURE(`greet_pause', `1000')dnl 1 seconds +# dnl # +# dnl # Delay_checks allows sender<->recipient checking +# FEATURE(`delay_checks', `friend', `n')dnl +# dnl # +# dnl # If we get too many bad recipients, slow things down... +# define(`confBAD_RCPT_THROTTLE',`3')dnl +# dnl # +# dnl # Stop connections that overflow our concurrent and time connection rates +# FEATURE(`conncontrol', `nodelay', `terminate')dnl +# FEATURE(`ratecontrol', `nodelay', `terminate')dnl +# dnl # +# dnl # If you're on a dialup link, you should enable this - so sendmail +# dnl # will not bring up the link (it will queue mail for later) +# dnl define(`confCON_EXPENSIVE',`True')dnl +# dnl # +# dnl # Dialup/LAN connection overrides +# dnl # +# include(`/etc/mail/m4/dialup.m4')dnl +# include(`/etc/mail/m4/provider.m4')dnl +# dnl # +# dnl # Default Mailer setup +# MAILER_DEFINITIONS +# MAILER(`local')dnl +# MAILER(`smtp')dnl +# diff --git a/integration/runner/config.go b/integration/runner/config.go new file mode 100644 index 0000000..fb14edd --- /dev/null +++ b/integration/runner/config.go @@ -0,0 +1,257 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "go/build" + "io/fs" + "math" + "os" + "path" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/d--j/go-milter/integration" +) + +type Config struct { + MtaStartPort uint16 + ReceiverPort uint16 + MilterPort uint16 + ScratchDir string + MTAs []string + TestDirs []*TestDir + Tests []*TestCase + Filter *regexp.Regexp +} + +func (c *Config) Cleanup() { + if c.ScratchDir != "" { + _ = os.RemoveAll(c.ScratchDir) + } +} + +func ParseConfig() *Config { + _, filename, _, ok := runtime.Caller(0) + if !ok { + panic("could not get path to runner.go") + } + mtaPath := "" + flag.StringVar(&mtaPath, "mta", path.Join(path.Dir(path.Dir(filename)), "mta"), "`path` to MTA definitions") + mtaPort := uint(34025) + flag.UintVar(&mtaPort, "mtaPort", 34025, "start `port` for the MTAs (1024 < port < 65536") + receiverPort := uint(34125) + flag.UintVar(&receiverPort, "receiverPort", 34125, "`port` for the next-hop SMTP server (1024 < port < 65536") + milterPort := uint(34126) + flag.UintVar(&milterPort, "milterPort", 34126, "`port` for test milter servers (1024 < port < 65536") + filter := "" + flag.StringVar(&filter, "filter", "", "regexp `pattern` to filter testcases") + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + fmt.Fprintf(flag.CommandLine.Output(), " test-dir...\n \tone ore more directories containing test filters and testcases\n") + } + flag.Parse() + if filter == "" { + filter = ".*" + } + filterRe, err := regexp.Compile(filter) + if err != nil { + LevelOneLogger.Fatal(err) + } + config := Config{ + MtaStartPort: uint16(mtaPort), + ReceiverPort: uint16(receiverPort), + MilterPort: uint16(milterPort), + Filter: filterRe, + ScratchDir: "", + } + tmpDir, err := os.MkdirTemp("", "scratch-*") + if err != nil { + LevelOneLogger.Fatal(err) + } + err = os.Chmod(tmpDir, 0755) + if err != nil { + LevelOneLogger.Fatal(err) + } + config.ScratchDir = tmpDir + if flag.NArg() == 0 { + flag.Usage() + os.Exit(1) + } + if mtaPort > math.MaxUint16 || mtaPort < 1025 || receiverPort > math.MaxUint16 || receiverPort < 1025 || milterPort > math.MaxUint16 || milterPort < 1025 { + flag.Usage() + os.Exit(1) + } + testDirs, err := expandTestDirs(flag.Args()) + if err != nil { + LevelOneLogger.Fatalf("error getting tests: %s", err) + } + mtas, err := filepath.Glob(path.Join(mtaPath, "*/mta.sh")) + if err != nil { + LevelOneLogger.Fatalf("error getting MTAs: %s", err) + } + if mtas == nil { + LevelOneLogger.Fatalf("did not find any MTAs") + } + if mtaPort+uint(len(mtas)) > math.MaxUint16 { + LevelOneLogger.Fatal("too many MTAs, pick a lower -mtaPort") + } + if overlap(receiverPort, receiverPort, mtaPort, mtaPort+uint(len(mtas))) { + LevelOneLogger.Fatal("-receiverPort and -mtaPort overlap") + } + if overlap(milterPort, milterPort, mtaPort, mtaPort+uint(len(mtas))) { + LevelOneLogger.Fatal("-milterPort and -mtaPort overlap") + } + if overlap(receiverPort, receiverPort, milterPort, milterPort) { + LevelOneLogger.Fatal("-receiverPort and -milterPort overlap") + } + var dirs []*TestDir + var tests []*TestCase + for _, p := range mtas { + mta, err := NewMTA(p, uint16(mtaPort), &config) + mtaPort++ + if err != nil { + LevelOneLogger.Printf("SKIP %s: %s", p, err) + continue + } + if mta == nil { + LevelOneLogger.Printf("SKIP %s: empty tag list", p) + continue + } + + for i, testDir := range testDirs { + dir := TestDir{ + Index: i, + Path: testDir, + Config: &config, + MTA: mta, + } + err = filepath.WalkDir(testDir, func(path string, d fs.DirEntry, err error) error { + if !d.IsDir() { + if filepath.Ext(path) == ".testcase" && filterRe.MatchString(path) { + testCase, err := integration.ParseTestCase(path) + if err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + test := &TestCase{ + Index: len(tests), + Filename: filepath.Base(path), + TestCase: testCase, + parent: &dir, + } + dir.Tests = append(dir.Tests, test) + tests = append(tests, test) + } + } else if path != testDir { + return filepath.SkipDir + } + return nil + }) + if err != nil { + LevelOneLogger.Fatal(err) + } + if len(dir.Tests) > 0 { + dirs = append(dirs, &dir) + } + } + } + if len(tests) == 0 { + LevelOneLogger.Fatal("did not find any tests") + } + + config.MTAs = mtas + config.TestDirs = dirs + config.Tests = tests + + if err := GenCert("localhost.local", config.ScratchDir); err != nil { + LevelOneLogger.Fatal(err) + } + + LevelOneLogger.Printf("OK %d test cases", len(tests)) + + return &config +} + +var tagsSplit = regexp.MustCompile("[\n\r]") + +func removeEmptyOrDuplicates(str []string) []string { + if len(str) == 0 { + return []string{} + } + found := make(map[string]bool, len(str)) + indexesToKeep := make([]int, 0, len(str)) + found[""] = true + for i, v := range str { + v = strings.TrimSpace(v) + if !found[v] { + indexesToKeep = append(indexesToKeep, i) + found[v] = true + } + } + noDuplicates := make([]string, len(indexesToKeep)) + for i, index := range indexesToKeep { + noDuplicates[i] = strings.TrimSpace(str[index]) + } + return noDuplicates +} + +func expandTestDirs(in []string) (dirs []string, err error) { + ctxt := build.Default // copy + ctxt.UseAllFiles = true + for len(in) > 0 { + candidate, err := filepath.Abs(in[0]) + in = in[1:] + if err != nil { + return nil, err + } + if stat, err := os.Stat(candidate); err != nil || !stat.IsDir() { + return nil, fmt.Errorf("path %s is not a directory", candidate) + } + pkg, err := ctxt.ImportDir(candidate, 0) + if err != nil { + if _, ok := err.(*build.NoGoError); ok { + err = filepath.WalkDir(candidate, func(path string, d fs.DirEntry, err error) error { + if err == nil && candidate != path && d.IsDir() { + in = append(in, path) + } + if d.IsDir() && candidate != path { + return filepath.SkipDir + } + return nil + }) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } else { + if !pkg.IsCommand() { + return nil, fmt.Errorf("path %s contains package %s not main", candidate, pkg.Name) + } + dirs = append(dirs, candidate) + } + + } + if len(dirs) == 0 { + return nil, errors.New("could not find any tests") + } + return +} + +func overlap(start1 uint, end1 uint, start2 uint, end2 uint) bool { + if end1 < start1 || end2 < start2 { + panic("end < start") + } + if start1 == start2 || end1 == end2 { + return true + } + if start1 > start2 { + return end2 >= start1 + } + return end1 >= start2 +} diff --git a/integration/runner/exec.go b/integration/runner/exec.go new file mode 100644 index 0000000..0a8040b --- /dev/null +++ b/integration/runner/exec.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + "log" + "net" + "os/exec" + "syscall" + "time" + + "github.com/d--j/go-milter/integration" +) + +func Build(goDir string, output string) error { + cmd := exec.Command("go", "build", "-gcflags=all=-l", "-o", output, goDir) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("%s", out) + } + return err +} + +func WaitForPort(ctx context.Context, port uint16) error { + for i := 0; i < 1200; i++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + time.Sleep(250 * time.Millisecond) + conn, err := net.Dial("tcp", fmt.Sprintf(":%d", port)) + if err == nil { + conn.Close() + return nil + } + } + return fmt.Errorf("timeout waiting for port %d to get ready", port) +} + +func IsExpectedExitErr(err error) bool { + if err == nil { + return true + } + if e, ok := err.(*exec.ExitError); ok { + if e.Success() || e.ExitCode() == integration.ExitSkip { + return true + } + status := e.Sys().(syscall.WaitStatus) + if status.Signaled() && (status.Signal() == syscall.SIGTERM || status.Signal() == syscall.SIGQUIT) { + return true + } + } + return false +} diff --git a/integration/runner/main.go b/integration/runner/main.go new file mode 100644 index 0000000..845a0b0 --- /dev/null +++ b/integration/runner/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "log" + "os" +) + +var LevelOneLogger = log.New(os.Stdout, "= ", 0) +var LevelTwoLogger = log.New(os.Stdout, "== ", 0) +var LevelThreeLogger = log.New(os.Stdout, "=== ", 0) + +func main() { + config := ParseConfig() + defer config.Cleanup() + receiver := Receiver{Config: config} + if err := receiver.Start(); err != nil { + LevelOneLogger.Fatal(err) + } + defer receiver.Cleanup() + runner := NewRunner(config, &receiver) + runner.Run() +} diff --git a/integration/runner/mta.go b/integration/runner/mta.go new file mode 100644 index 0000000..6da20c6 --- /dev/null +++ b/integration/runner/mta.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path" + "strings" + "sync" + "syscall" + "time" +) + +type MTA struct { + path string + Port uint16 + cmd *exec.Cmd + dir string + tags []string + config *Config + wg sync.WaitGroup + once sync.Once + m sync.Mutex + failedTest bool +} + +func NewMTA(path string, port uint16, config *Config) (*MTA, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + tagsCmd := exec.CommandContext(ctx, path, "tags") + out, err := tagsCmd.Output() + cancel() + if err != nil { + return nil, fmt.Errorf("executing %s tags failed: %w", path, err) + } + tags := removeEmptyOrDuplicates(tagsSplit.Split(string(out), -1)) + if len(tags) == 0 { + return nil, nil + } + return &MTA{ + path: path, + Port: port, + tags: tags, + config: config, + }, nil +} + +func (m *MTA) String() string { + return fmt.Sprintf("%s (%s)", m.path, strings.Join(m.tags, ", ")) +} + +func (m *MTA) HasTag(tag string) bool { + for _, t := range m.tags { + if t == tag { + return true + } + } + return false +} + +func (m *MTA) MarkFailedTest() { + m.m.Lock() + defer m.m.Unlock() + m.failedTest = true +} + +func (m *MTA) Start() error { + m.dir = path.Join(m.config.ScratchDir, fmt.Sprintf("mta-%d", m.Port)) + err := os.Mkdir(m.dir, 0755) + if err != nil && !os.IsExist(err) { + return err + } + m.cmd = exec.Command(m.path, "start", + "-mtaPort", fmt.Sprintf("%d", m.Port), + "-receiverPort", fmt.Sprintf("%d", m.config.ReceiverPort), + "-milterPort", fmt.Sprintf("%d", m.config.MilterPort), + "-scratchDir", m.dir, + ) + for _, t := range m.tags { + if strings.HasPrefix(t, "sleep-") { + d, err := time.ParseDuration(t[6:]) + if err != nil { + return err + } + defer func(d time.Duration) { time.Sleep(d) }(d) + break + } + } + ctx, cancel := context.WithCancel(context.Background()) + m.wg.Add(1) + go func() { + b, err := m.cmd.CombinedOutput() + failed := !IsExpectedExitErr(err) + if failed { + LevelTwoLogger.Print(err) + } + m.m.Lock() + failedTest := m.failedTest + m.m.Unlock() + if failed || failedTest { + LevelTwoLogger.Printf("MTA %s\n%s", m.path, b) + } + m.wg.Done() + cancel() + }() + err = WaitForPort(ctx, m.Port) + cancel() + if err != nil { + m.Stop() + return err + } + LevelTwoLogger.Printf("MTA %s ready", m.path) + return nil +} + +func (m *MTA) Stop() { + m.once.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + b, _ := exec.CommandContext(ctx, m.path, "stop", + "-mtaPort", fmt.Sprintf("%d", m.Port), + "-receiverPort", fmt.Sprintf("%d", m.config.ReceiverPort), + "-milterPort", fmt.Sprintf("%d", m.config.MilterPort), + "-scratchDir", m.dir, + ).CombinedOutput() + cancel() + if m.cmd != nil && m.cmd.Process != nil { + _ = m.cmd.Process.Signal(syscall.SIGTERM) + m.wg.Wait() + m.cmd = nil + } + m.m.Lock() + failedTest := m.failedTest + m.m.Unlock() + if failedTest { + LevelTwoLogger.Printf("MTA %s stop\n%s", m.path, b) + } + }) +} diff --git a/integration/runner/receiver.go b/integration/runner/receiver.go new file mode 100644 index 0000000..d34540e --- /dev/null +++ b/integration/runner/receiver.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "net" + "regexp" + "sync" + "time" + + "github.com/d--j/go-milter/integration" + "github.com/emersion/go-smtp" +) + +type Receiver struct { + Msg chan *integration.Output + expectOutput bool + m sync.Mutex + Config *Config + s *smtp.Server +} + +type receiverBackend struct { + receiver *Receiver +} + +type ReceiverSession struct { + Hostname string + Output *integration.Output + receiver *Receiver +} + +func (rs *ReceiverSession) Reset() { + rs.Output = nil +} + +func (rs *ReceiverSession) Logout() error { + return nil +} + +func (rs *ReceiverSession) AuthPlain(_, _ string) error { + return errors.New("no auth") +} + +func (rs *ReceiverSession) Mail(from string, opts *smtp.MailOptions) error { + if rs.Output == nil { + rs.Output = &integration.Output{} + } + rs.Output.From = integration.ToAddrArg(from, opts) + return nil +} + +func (rs *ReceiverSession) Rcpt(to string) error { + rs.Output.To = append(rs.Output.To, integration.ToAddrArg(to, nil)) + return nil +} + +func (rs *ReceiverSession) Data(r io.Reader) (err error) { + var b []byte + b, err = io.ReadAll(r) + if err != nil { + return + } + endHeaders := bytes.Index(b, []byte("\r\n\r\n")) + if endHeaders < 0 { + return fmt.Errorf("no end header marker found: %q", b) + } + rs.Output.Header = b[:endHeaders+4] + rs.Output.Body = b[endHeaders+4:] + rs.receiver.onMsg(rs.Output) + return +} + +func (r *receiverBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &ReceiverSession{Hostname: c.Hostname(), receiver: r.receiver}, nil +} + +func (r *Receiver) Start() error { + r.Msg = make(chan *integration.Output, 100) + s := smtp.NewServer(&receiverBackend{receiver: r}) + s.Addr = fmt.Sprintf(":%d", r.Config.ReceiverPort) + s.Domain = "localhost" + s.ReadTimeout = 10 * time.Second + s.WriteTimeout = 10 * time.Second + s.MaxMessageBytes = 1024 * 1024 + s.MaxRecipients = 50 + s.AllowInsecureAuth = true + s.EnableSMTPUTF8 = true + s.EnableREQUIRETLS = true + + l, err := net.Listen("tcp", s.Addr) + if err != nil { + return err + } + + go func() { + _ = s.Serve(l) + }() + + r.s = s + + return nil +} + +func (r *Receiver) clearMessages() { + for { + select { + case o := <-r.Msg: + r.onUnexpectedMsg(o) + default: + return + } + } +} + +func (r *Receiver) ExpectMessage() { + r.m.Lock() + defer r.m.Unlock() + r.expectOutput = true + r.clearMessages() +} + +func (r *Receiver) IgnoreMessages() { + r.m.Lock() + defer r.m.Unlock() + r.expectOutput = false + r.clearMessages() +} + +var receiverMatch = regexp.MustCompile("(?ms)^Received:.*?(\r\n[^ \t])") + +func (r *Receiver) WaitForMessage() *integration.Output { + select { + case <-time.After(time.Second * 20): + return nil + case o := <-r.Msg: + if o.Header != nil { + // replace the first received line with a placeholder + loc := receiverMatch.FindIndex(o.Header) + if len(loc) == 2 { + o.Header = append(o.Header[:loc[0]], append([]byte("Received: placeholder"), o.Header[loc[1]-3:]...)...) + } + } + return o + } +} + +func (r *Receiver) onMsg(output *integration.Output) { + r.m.Lock() + defer r.m.Unlock() + if r.expectOutput { + r.Msg <- output + } else { + r.onUnexpectedMsg(output) + } +} + +func (r *Receiver) onUnexpectedMsg(output *integration.Output) { + log.Printf("WARN: unexpected message received: %s", output) +} + +func (r *Receiver) Cleanup() { + _ = r.s.Close() +} diff --git a/integration/runner/runner.go b/integration/runner/runner.go new file mode 100644 index 0000000..db1146e --- /dev/null +++ b/integration/runner/runner.go @@ -0,0 +1,109 @@ +package main + +import ( + "os" + + "github.com/d--j/go-milter/integration" +) + +type Runner struct { + config *Config + receiver *Receiver +} + +func NewRunner(config *Config, receiver *Receiver) *Runner { + return &Runner{ + config: config, + receiver: receiver, + } +} + +func (r *Runner) Run() { + var prevMta *MTA + var prevDir *TestDir + defer func() { + if prevDir != nil { + prevDir.Stop() + } + if prevMta != nil { + prevMta.Stop() + } + }() + tests := len(r.config.Tests) + i := 0 + for _, dir := range r.config.TestDirs { + if prevMta != dir.MTA { + if prevMta != nil { + prevMta.Stop() + } + LevelOneLogger.Print(dir.MTA) + prevMta = dir.MTA + if err := dir.MTA.Start(); err != nil { + LevelTwoLogger.Fatal(err) + } + } + prevDir = dir + LevelTwoLogger.Print(dir.Path) + if err := dir.Start(); err != nil { + if err == ErrTestSkipped { + for _, t := range dir.Tests { + i++ + LevelThreeLogger.Printf("%03d/%03d %s", i+1, tests, t.Filename) + t.MarkSkipped("%03d/%03d SKIP", i, tests) + } + continue + } + LevelTwoLogger.Fatal(err) + } + for _, t := range dir.Tests { + i++ + LevelThreeLogger.Printf("%03d/%03d %s", i, tests, t.Filename) + if t.TestCase.ExpectsOutput() { + r.receiver.ExpectMessage() + } + code, message, step, err := t.Send(t.TestCase.InputSteps, dir.MTA.Port) + if err != nil { + prevMta.MarkFailedTest() + prevMta.Stop() + LevelThreeLogger.Fatal(err) + } + if !t.TestCase.Decision.Compare(code, message, step) { + r.receiver.IgnoreMessages() + t.MarkFailed("%03d/%03d NOK DECISION %s != %d %s @%s", i, tests, t.TestCase.Decision, code, message, step) + continue + } + if t.TestCase.ExpectsOutput() { + output := r.receiver.WaitForMessage() + r.receiver.IgnoreMessages() + diff, ok := integration.DiffOutput(t.TestCase.Output, output) + if !ok { + if t.parent.MTA.HasTag("mta-sendmail") { + if integration.CompareOutputSendmail(t.TestCase.Output, output) { + t.MarkOk("%03d/%03d OK (sendmail) %s", i, tests, diff) + continue + } + } + t.MarkFailed("%03d/%03d NOK OUTPUT %s", i, tests, diff) + continue + } + } + t.MarkOk("%03d/%03d OK", i, tests) + } + prevDir.Stop() + } + numOk, numSkipped, numFailed := 0, 0, 0 + for _, t := range r.config.Tests { + switch t.State { + case TestOk: + numOk++ + case TestSkipped: + numSkipped++ + case TestFailed: + numFailed++ + } + } + LevelOneLogger.Printf("%d tests done: %d OK %d skipped %d failed", len(r.config.Tests), numOk, numSkipped, numFailed) + if numFailed > 0 { + os.Exit(1) + } +} diff --git a/integration/runner/test.go b/integration/runner/test.go new file mode 100644 index 0000000..0c7a0ce --- /dev/null +++ b/integration/runner/test.go @@ -0,0 +1,218 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "strings" + "sync" + "syscall" + "time" + + "github.com/d--j/go-milter/integration" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +var ErrTestSkipped = errors.New("test skipped") + +type TestDir struct { + Index int + Path string + Config *Config + MTA *MTA + Tests []*TestCase + cmd *exec.Cmd + wg sync.WaitGroup + once sync.Once + m sync.Mutex + startErr error + failedTest bool +} + +func (t *TestDir) Start() error { + p := path.Join(t.Config.ScratchDir, fmt.Sprintf("test-%d", t.Index)) + err := os.Mkdir(p, 0700) + if err != nil && !os.IsExist(err) { + return err + } + exe := path.Join(p, "test.exe") + if err := Build(t.Path, exe); err != nil { + return err + } + t.cmd = exec.Command(exe, "-network", "tcp", "-address", fmt.Sprintf(":%d", t.Config.MilterPort), "-tags", strings.Join(t.MTA.tags, " ")) + ctx, cancel := context.WithCancel(context.Background()) + t.wg.Add(1) + go func() { + b, err := t.cmd.CombinedOutput() + t.m.Lock() + t.startErr = err + failedTest := t.failedTest + t.m.Unlock() + failed := !IsExpectedExitErr(err) + if failed { + LevelTwoLogger.Print(err) + } + if failed || failedTest { + LevelTwoLogger.Printf("DIR %s\n%s", t.Path, b) + } + t.wg.Done() + cancel() + }() + time.Sleep(time.Second) + t.m.Lock() + err = t.startErr + t.m.Unlock() + if err != nil { + if e, ok := err.(*exec.ExitError); ok { + if e.ExitCode() == integration.ExitSkip { + return ErrTestSkipped + } + } + return err + } + err = WaitForPort(ctx, t.Config.MilterPort) + cancel() + if err != nil { + t.Stop() + return err + } + return nil +} + +func (t *TestDir) Stop() { + t.once.Do(func() { + if t.cmd != nil && t.cmd.Process != nil { + t.cmd.Process.Signal(syscall.SIGTERM) + t.cmd = nil + t.wg.Wait() + } + }) +} + +func (t *TestDir) MarkFailedTest() { + t.m.Lock() + defer t.m.Unlock() + t.failedTest = true + t.MTA.MarkFailedTest() +} + +type TestState int + +const ( + TestReady TestState = iota + TestOk + TestSkipped + TestFailed +) + +type TestCase struct { + Index int + Path string + Filename string + TestCase *integration.TestCase + smtpData bytes.Buffer + Config *Config + parent *TestDir + State TestState +} + +func (t *TestCase) MarkFailed(format string, v ...any) { + t.parent.MarkFailedTest() + t.State = TestFailed + LevelThreeLogger.Printf(format, v...) + LevelThreeLogger.Printf("SMTP transaction:\n%s", t.smtpData.String()) +} + +func (t *TestCase) MarkSkipped(format string, v ...any) { + LevelThreeLogger.Printf(format, v...) + t.State = TestSkipped +} + +func (t *TestCase) MarkOk(format string, v ...any) { + LevelThreeLogger.Printf(format, v...) + t.State = TestOk +} + +type logWriter struct { + t *TestCase +} + +func (l *logWriter) Write(p []byte) (n int, err error) { + return l.t.smtpData.Write(p) +} + +func (t *TestCase) Send(steps []*integration.InputStep, port uint16) (uint16, string, integration.DecisionStep, error) { + client, err := smtp.Dial(fmt.Sprintf(":%d", port)) + if err != nil { + return 0, "", integration.StepAny, err + } + defer client.Close() + client.DebugWriter = &logWriter{t: t} + var dataWriter io.WriteCloser + for _, step := range steps { + switch step.What { + case "HELO": + if err := client.Hello(step.Arg); err != nil { + return smtpErr(err, integration.StepHelo) + } + case "STARTTLS": + if err := client.StartTLS(&tls.Config{InsecureSkipVerify: true}); err != nil { + return smtpErr(err, integration.StepAny) + } + case "AUTH": + password := "password1" + if step.Arg == "user2@example.com" { + password = "password2" + } + if err := client.Auth(sasl.NewPlainClient("", step.Arg, password)); err != nil { + return smtpErr(err, integration.StepAny) + } + case "FROM": + if err := client.Mail(step.Addr, nil); err != nil { + return smtpErr(err, integration.StepFrom) + } + case "TO": + if err := client.Rcpt(step.Addr); err != nil { + return smtpErr(err, integration.StepTo) + } + case "RESET": + if err := client.Reset(); err != nil { + return smtpErr(err, integration.StepAny) + } + case "HEADER": + dataWriter, err = client.Data() + if err != nil { + return smtpErr(err, integration.StepData) + } + if _, err := dataWriter.Write(step.Data); err != nil { + return smtpErr(err, integration.StepAny) + } + case "BODY": + if _, err := dataWriter.Write(step.Data); err != nil { + return smtpErr(err, integration.StepAny) + } + if err := dataWriter.Close(); err != nil { + return smtpErr(err, integration.StepEOM) + } + _ = client.Quit() + return 250, "OK: queued", integration.StepEOM, nil + default: + return 0, "", integration.StepAny, fmt.Errorf("unknown step %s", step.What) + } + } + return 0, "", integration.StepEOM, errors.New("incomplete input sequence") +} + +func smtpErr(err error, step integration.DecisionStep) (uint16, string, integration.DecisionStep, error) { + if sErr, ok := err.(*smtp.SMTPError); ok { + return uint16(sErr.Code), sErr.Message, step, nil + } + return 0, "", step, err +} diff --git a/integration/runner/tls.go b/integration/runner/tls.go new file mode 100644 index 0000000..2887668 --- /dev/null +++ b/integration/runner/tls.go @@ -0,0 +1,65 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path" + "time" +) + +func GenCert(host string, outDir string) error { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(time.Hour * 24), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{host}, + } + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + b := &bytes.Buffer{} + err = pem.Encode(b, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + if err != nil { + return fmt.Errorf("failed to encode certificate: %w", err) + } + f, err := os.OpenFile(path.Join(outDir, "cert.pem"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create certificate file: %w", err) + } + _, err = f.Write(b.Bytes()) + _ = f.Close() + if err != nil { + return fmt.Errorf("failed to write certificate file: %w", err) + } + b.Reset() + err = pem.Encode(b, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + if err != nil { + return fmt.Errorf("failed to encode key: %w", err) + } + f, err = os.OpenFile(path.Join(outDir, "key.pem"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create certificate file: %w", err) + } + _, err = f.Write(b.Bytes()) + _ = f.Close() + if err != nil { + return fmt.Errorf("failed to write certificate file: %w", err) + } + return nil +} diff --git a/integration/testcase.go b/integration/testcase.go new file mode 100644 index 0000000..20bfcca --- /dev/null +++ b/integration/testcase.go @@ -0,0 +1,770 @@ +package integration + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net/textproto" + "os" + "reflect" + "regexp" + "strings" + "time" + + "github.com/d--j/go-milter/milterutil" + "github.com/emersion/go-message/mail" + msgTextproto "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" + "golang.org/x/text/transform" +) + +type AddrArg struct { + Addr, Arg string +} + +func ToAddrArg(addr string, options *smtp.MailOptions) *AddrArg { + var aa AddrArg + aa.Addr = addr + if options == nil { + return &aa + } + var args []string + if options.Body != "" { + args = append(args, fmt.Sprintf("BODY=%s", options.Body)) + } + if options.Size > 0 { + args = append(args, fmt.Sprintf("SIZE=%d", options.Size)) + } + if options.UTF8 { + args = append(args, "SMTPUTF8") + } + if options.RequireTLS { + args = append(args, "REQUIRETLS") + } + if options.Auth != nil { + args = append(args, fmt.Sprintf("AUTH=<%s>", *options.Auth)) + } + aa.Arg = strings.Join(args, " ") + return &aa +} + +type InputStep struct { + What string + Addr, Arg string + Data []byte +} +type DecisionStep int + +const ( + StepAny DecisionStep = iota + StepHelo + StepFrom + StepTo + StepData + StepEOM +) + +func (s DecisionStep) String() string { + switch s { + case StepAny: + return "*" + case StepHelo: + return "HELO" + case StepFrom: + return "FROM" + case StepTo: + return "TO" + case StepData: + return "DATA" + case StepEOM: + return "EOM" + } + return fmt.Sprintf("", s) +} + +type Decision struct { + Code int + Message *string + Step DecisionStep +} + +func (d Decision) Compare(code uint16, message string, step DecisionStep) bool { + if d.Step != StepAny { + if d.Step != step { + return false + } + } + if d.Code < 10 { + return code/100 == uint16(d.Code) + } + if d.Code < 100 { + return code/10 == uint16(d.Code) + } + if d.Message != nil { + return code == uint16(d.Code) && message == *d.Message + } + return code == uint16(d.Code) +} + +func (d Decision) String() string { + if d.Code < 10 { + return fmt.Sprintf("%dxx@%s", d.Code, d.Step) + } + if d.Code < 100 { + return fmt.Sprintf("%dx@%s", d.Code, d.Step) + } + if d.Message != nil { + return fmt.Sprintf("%d %s@%s", d.Code, *d.Message, d.Step) + } + return fmt.Sprintf("%d@%s", d.Code, d.Step) +} + +type Output struct { + From *AddrArg + To []*AddrArg + Header, Body []byte +} + +func (o *Output) String() string { + var b strings.Builder + if o.From != nil { + b.WriteString("FROM\n") + b.WriteString(fmt.Sprintf("- <%s> %s\n", o.From.Addr, o.From.Arg)) + } + if o.To != nil { + b.WriteString("TO\n") + for _, t := range o.To { + b.WriteString(fmt.Sprintf("- <%s> %s\n", t.Addr, t.Arg)) + } + } + if o.Header != nil { + b.WriteString("HEADER\n") + b.WriteString(fmt.Sprintf("- %q\n", o.Header)) + + } + if o.Body != nil { + b.WriteString("BODY\n") + b.WriteString(fmt.Sprintf("- %q\n", o.Body)) + } + return b.String() +} + +type TestCase struct { + InputSteps []*InputStep + Decision *Decision + Output *Output +} + +func (c *TestCase) ExpectsOutput() bool { + return c.Output != nil +} + +var constantDate = time.Date(2023, time.January, 1, 12, 0, 0, 0, time.UTC) + +const ( + stepHelo = 1 << iota + stepStarttls + stepAuth + stepFrom + stepRcpt + stepHdr + stepBody +) + +func ParseTestCase(filename string) (*TestCase, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + r := textproto.NewReader(bufio.NewReader(f)) + steps := 0 + var inputs []*InputStep + var decision *Decision + var output *Output + for true { + line, err := r.ReadLine() + if err == io.EOF { + if line != "" { + return nil, fmt.Errorf("parsing error: dangling %q", line) + } + break + } + if err != nil { + return nil, err + } + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "HELO "): + if decision != nil { + return nil, errors.New("HELO after DECISION") + } + inputs, steps, err = inputHelo(line[5:], inputs, steps) + if err != nil { + return nil, err + } + case line == "STARTTLS": + if decision != nil { + return nil, errors.New("STARTTLS after DECISION") + } + if steps&stepFrom != 0 { + return nil, errors.New("can only handle STARTTLS as first command after HELO") + } + if steps&stepStarttls != 0 { + return nil, errors.New("multiple STARTTLS are invalid") + } + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + steps = steps | stepStarttls + inputs = append(inputs, &InputStep{What: "STARTTLS"}) + case strings.HasPrefix(line, "AUTH "): + if decision != nil { + return nil, errors.New("AUTH after DECISION") + } + if steps&stepAuth != 0 { + return nil, errors.New("only one AUTH") + } + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + steps = steps | stepAuth + user := strings.TrimSpace(line[5:]) + switch user { + case "user1@example.com", "user2@example.com": + inputs = append(inputs, &InputStep{What: "AUTH", Arg: user}) + default: + return nil, fmt.Errorf("unknown AUTH user %q", user) + } + case strings.HasPrefix(line, "FROM "): + if decision != nil { + if output == nil { + output = &Output{} + } + if output.From != nil { + return nil, errors.New("only one FROM line after DECISION") + } + addr, err := parseAddr(line[5:]) + if err != nil { + return nil, err + } + output.From = addr + } else { + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + inputs, steps, err = inputFrom(line[5:], inputs, steps) + if err != nil { + return nil, err + } + } + case strings.HasPrefix(line, "TO "): + if decision != nil { + if output == nil { + output = &Output{} + } + addr, err := parseAddr(line[3:]) + if err != nil { + return nil, err + } + output.To = append(output.To, addr) + } else { + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepFrom == 0 { + inputs, steps, err = inputFrom("", inputs, steps) + if err != nil { + return nil, err + } + } + inputs, steps, err = inputRcpt(line[3:], inputs, steps) + if err != nil { + return nil, err + } + } + case line == "RESET": + if decision != nil { + return nil, errors.New("RESET after DECISION") + } + if steps&stepHdr != 0 { + return nil, errors.New("RESET after HEADER does not make sense") + } + steps = steps & stepStarttls + inputs = append(inputs, &InputStep{What: "RESET"}) + case line == "HEADER": + if decision != nil { + if output == nil { + output = &Output{} + } + if output.Header != nil { + return nil, errors.New("only one HEADER line after DECISION") + } + output.Header, err = r.ReadDotBytes() + if err != nil { + return nil, err + } + output.Header = normalizeHeader(output.Header) + } else { + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepFrom == 0 { + inputs, steps, err = inputFrom("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepRcpt == 0 { + inputs, steps, err = inputRcpt("", inputs, steps) + if err != nil { + return nil, err + } + } + inputs, steps, err = inputHdr(r, inputs, steps) + if err != nil { + return nil, err + } + } + case line == "BODY": + if decision != nil { + if output == nil { + output = &Output{} + } + if output.Body != nil { + return nil, errors.New("only one BODY line after DECISION") + } + output.Body, err = r.ReadDotBytes() + if err != nil { + return nil, err + } + output.Body = normalizeBody(output.Body) + } else { + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepFrom == 0 { + inputs, steps, err = inputFrom("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepRcpt == 0 { + inputs, steps, err = inputRcpt("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepHdr == 0 { + inputs, steps, err = inputHdr(nil, inputs, steps) + if err != nil { + return nil, err + } + } + inputs, steps, err = inputBody(r, inputs, steps) + if err != nil { + return nil, err + } + } + case strings.HasPrefix(line, "DECISION "): + if decision != nil { + return nil, errors.New("only one DECISION line") + } + if steps&stepHelo == 0 { + inputs, steps, err = inputHelo("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepFrom == 0 { + inputs, steps, err = inputFrom("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepRcpt == 0 { + inputs, steps, err = inputRcpt("", inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepHdr == 0 { + inputs, steps, err = inputHdr(nil, inputs, steps) + if err != nil { + return nil, err + } + } + if steps&stepBody == 0 { + inputs, steps, err = inputBody(nil, inputs, steps) + if err != nil { + return nil, err + } + } + decision, err = parseDecision(line[9:], r) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("parsing error: unknown line %q", line) + } + } + + if decision == nil { + return nil, errors.New("no DECISION line specified") + } + + return &TestCase{ + InputSteps: inputs, + Decision: decision, + Output: output, + }, nil +} + +func inputHelo(input string, inputs []*InputStep, steps int) ([]*InputStep, int, error) { + if steps&stepFrom != 0 { + return nil, steps, errors.New("cannot use HELO after FROM") + } + steps = steps | stepHelo + helo := strings.TrimSpace(input) + if helo == "" { + helo = "localhost.local" + } + inputs = append(inputs, &InputStep{What: "HELO", Arg: helo}) + return inputs, steps, nil +} + +var angleAddr = regexp.MustCompile("^\\s*<(.*?)>\\s*(.*?)\\s*$") + +func parseAddr(input string) (*AddrArg, error) { + matches := angleAddr.FindStringSubmatch(input) + if matches == nil { + return nil, fmt.Errorf("could not parse %q", input) + } + return &AddrArg{Addr: matches[1], Arg: matches[2]}, nil +} + +func inputFrom(input string, inputs []*InputStep, steps int) ([]*InputStep, int, error) { + if steps&stepFrom != 0 { + return nil, steps, errors.New("cannot use FROM multiple times") + } + steps = steps | stepFrom + addr, err := parseAddr(input) + if err != nil { + return nil, steps, err + } + inputs = append(inputs, &InputStep{What: "FROM", Addr: addr.Addr, Arg: addr.Arg}) + return inputs, steps, nil +} + +func inputRcpt(input string, inputs []*InputStep, steps int) ([]*InputStep, int, error) { + if steps&stepHdr != 0 { + return nil, steps, errors.New("cannot use TO after HEADER, use RESET in-between") + } + steps = steps | stepRcpt + addr, err := parseAddr(input) + if err != nil { + return nil, steps, err + } + inputs = append(inputs, &InputStep{What: "TO", Addr: addr.Addr, Arg: addr.Arg}) + return inputs, steps, nil +} + +func normalizeHeader(in []byte) []byte { + b, _, err := transform.Bytes(&milterutil.CrLfCanonicalizationTransformer{}, in) + if err != nil { + panic(err) + } + if len(b) < 4 || !bytes.Equal(b[len(b)-4:], []byte("\r\n\r\n")) { + b = append(b, '\r', '\n') + } + return b +} + +func normalizeBody(in []byte) []byte { + b, _, err := transform.Bytes(&milterutil.CrLfCanonicalizationTransformer{}, in) + if err != nil { + panic(err) + } + return b +} + +func inputHdr(r *textproto.Reader, inputs []*InputStep, steps int) ([]*InputStep, int, error) { + if steps&stepHdr != 0 { + return nil, steps, errors.New("no multiple HEADER") + } + var b []byte + var err error + if r == nil { + var to []*mail.Address + for i := len(inputs) - 1; i > -1; i-- { + if inputs[i].What == "TO" { + to = append([]*mail.Address{{Address: inputs[i].Addr}}, to...) + } + if inputs[i].What == "FROM" { + hdr := mail.HeaderFromMap(nil) + hdr.SetMessageID("bogus-msg-id@example.com") + hdr.SetDate(constantDate) + hdr.SetText("Subject", "test") + hdr.SetAddressList("To", to) + hdr.SetAddressList("From", []*mail.Address{{Address: inputs[i].Addr}}) + buff := bytes.Buffer{} + err = msgTextproto.WriteHeader(&buff, hdr.Header.Header) + if err != nil { + return nil, steps, err + } + b = buff.Bytes() + break + } + } + } else { + raw, err := r.ReadDotBytes() + if err != nil { + return nil, steps, err + } + b = normalizeHeader(raw) + } + steps = steps | stepHdr + inputs = append(inputs, &InputStep{What: "HEADER", Data: b}) + return inputs, steps, nil +} + +func inputBody(r *textproto.Reader, inputs []*InputStep, steps int) ([]*InputStep, int, error) { + if steps&stepBody != 0 { + return nil, steps, errors.New("no multiple BODY") + } + var b []byte + if r == nil { + b = []byte("a test message") + } else { + raw, err := r.ReadDotBytes() + if err != nil { + return nil, steps, err + } + b, _, err = transform.Bytes(&milterutil.CrLfCanonicalizationTransformer{}, raw) + if err != nil { + return nil, steps, err + } + b = normalizeBody(b) + } + steps = steps | stepBody + inputs = append(inputs, &InputStep{What: "BODY", Data: b}) + return inputs, steps, nil +} + +func parseDecision(decisionStr string, r *textproto.Reader) (*Decision, error) { + decisionStr = strings.TrimSpace(decisionStr) + parts := strings.Split(decisionStr, "@") + if len(parts) > 2 { + return nil, fmt.Errorf("invalid decision %s", decisionStr) + } + at := StepAny + if len(parts) == 2 { + switch parts[1] { + case "HELO": + at = StepHelo + case "FROM": + at = StepFrom + case "TO": + at = StepTo + case "DATA": + at = StepData + case "EOM": + at = StepEOM + case "*": + at = StepAny + default: + return nil, fmt.Errorf("unkonwn step %s", parts[1]) + } + } + switch parts[0] { + case "ACCEPT", "DISCARD-OR-QUARANTINE": + if at != StepEOM && at != StepAny { + return nil, fmt.Errorf("step can only be * or EOM here %s", decisionStr) + } + return &Decision{Code: 2, Step: at}, nil + case "TEMPFAIL": + return &Decision{Code: 4, Step: at}, nil + case "REJECT": + return &Decision{Code: 5, Step: at}, nil + case "CUSTOM": + code, message, err := r.ReadResponse(0) + if err != nil { + return nil, err + } + return &Decision{Code: code, Message: &message, Step: at}, nil + default: + return nil, fmt.Errorf("unknown decision %q", decisionStr) + } +} + +func addrEqual(expected, got *AddrArg) bool { + if expected == nil && got == nil { + return true + } + if (expected == nil) != (got == nil) { + return false + } + if expected.Addr != got.Addr { + return false + } + if expected.Arg != "*" && got.Arg != "*" { + return expected.Addr == got.Arg + } + return true +} + +func addrsEqual(expected, got []*AddrArg) bool { + if expected == nil && got == nil { + return true + } + if (expected == nil) != (got == nil) { + return false + } + counter := 0 +outer: + for _, e := range expected { + for _, g := range got { + if addrEqual(e, g) { + counter++ + continue outer + } + } + return false + } + return counter == len(got) +} + +func DiffOutput(expected, got *Output) (string, bool) { + if expected == nil && got == nil { + return "", true + } + if expected != nil && got == nil { + return "got nil output", false + } + if expected == nil && got != nil { + return "expected nil", false + } + var b strings.Builder + ok := true + if expected.From != nil && !addrEqual(expected.From, got.From) { + ok = false + b.WriteString("FROM\n") + if expected.From == nil { + b.WriteString("- [nil]\n") + } else { + b.WriteString(fmt.Sprintf("- <%s> %s\n", expected.From.Addr, expected.From.Arg)) + } + if got.From == nil { + b.WriteString("+ [nil]\n") + } else { + b.WriteString(fmt.Sprintf("+ <%s> %s\n", got.From.Addr, got.From.Arg)) + } + } + if expected.To != nil && !addrsEqual(expected.To, got.To) { + ok = false + b.WriteString("TO\n") + if expected.To == nil { + b.WriteString("- [nil]\n") + } else { + for _, t := range expected.To { + b.WriteString(fmt.Sprintf("- <%s> %s\n", t.Addr, t.Arg)) + } + } + if got.To == nil { + b.WriteString("+ [nil]\n") + } else { + for _, t := range got.To { + b.WriteString(fmt.Sprintf("+ <%s> %s\n", t.Addr, t.Arg)) + } + } + } + if expected.Header != nil && !reflect.DeepEqual(expected.Header, got.Header) { + ok = false + b.WriteString("HEADER\n") + if expected.Header == nil { + b.WriteString("- [nil]\n") + } else { + b.WriteString(fmt.Sprintf("- %q\n", expected.Header)) + } + if got.Header == nil { + b.WriteString("+ [nil]\n") + } else { + b.WriteString(fmt.Sprintf("+ %q\n", got.Header)) + } + } + if expected.Body != nil && !reflect.DeepEqual(expected.Body, got.Body) { + ok = false + b.WriteString("BODY\n") + if expected.Body == nil { + b.WriteString("- [nil]\n") + } else { + b.WriteString(fmt.Sprintf("- %q\n", expected.Body)) + } + if got.Body == nil { + b.WriteString("+ [nil]\n") + } else { + b.WriteString(fmt.Sprintf("+ %q\n", got.Body)) + } + } + return b.String(), ok +} + +// CompareOutputSendmail is a relaxed compare function that does only check +// that the header values are all there – the order does not matter. +func CompareOutputSendmail(expected, got *Output) bool { + if expected == nil && got == nil { + return true + } + if expected != nil && got == nil { + return false + } + if expected == nil && got != nil { + return false + } + if expected.From != nil && !addrEqual(expected.From, got.From) { + return false + } + if expected.To != nil && !addrsEqual(expected.To, got.To) { + return false + } + if expected.Body != nil && !reflect.DeepEqual(expected.Body, got.Body) { + return false + } + if expected.Header != nil { + expectedLines := bytes.Split(expected.Header, []byte{'\r', '\n'}) + gotLines := bytes.Split(got.Header, []byte{'\r', '\n'}) + if len(expectedLines) != len(gotLines) { + return false + } + outer: + for _, e := range expectedLines { + for _, g := range gotLines { + if bytes.Equal(e, g) { + continue outer + } + } + return false + } + } + return true +} diff --git a/integration/tests/auth/no-auth.testcase b/integration/tests/auth/no-auth.testcase new file mode 100644 index 0000000..c6ecb5b --- /dev/null +++ b/integration/tests/auth/no-auth.testcase @@ -0,0 +1,4 @@ +STARTTLS +FROM +DECISION CUSTOM@FROM +501 No authentication diff --git a/integration/tests/auth/no-tls.testcase b/integration/tests/auth/no-tls.testcase new file mode 100644 index 0000000..3874e9c --- /dev/null +++ b/integration/tests/auth/no-tls.testcase @@ -0,0 +1,4 @@ +AUTH user1@example.com +FROM +DECISION CUSTOM@FROM +500 No starttls diff --git a/integration/tests/auth/ok.testcase b/integration/tests/auth/ok.testcase new file mode 100644 index 0000000..87da8d8 --- /dev/null +++ b/integration/tests/auth/ok.testcase @@ -0,0 +1,5 @@ +STARTTLS +AUTH user1@example.com +FROM +DECISION CUSTOM@FROM +502 Ok diff --git a/integration/tests/auth/test.go b/integration/tests/auth/test.go new file mode 100644 index 0000000..4f88efc --- /dev/null +++ b/integration/tests/auth/test.go @@ -0,0 +1,21 @@ +package main + +import ( + "context" + + "github.com/d--j/go-milter/integration" + "github.com/d--j/go-milter/mailfilter" +) + +func main() { + integration.RequiredTags("auth-plain", "auth-no", "tls-starttls", "tls-no") + integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) { + if trx.Helo.TlsVersion == "" { + return mailfilter.CustomErrorResponse(500, "No starttls"), nil + } + if trx.MailFrom.AuthenticatedUser() == "user1@example.com" { + return mailfilter.CustomErrorResponse(502, "Ok"), nil + } + return mailfilter.CustomErrorResponse(501, "No authentication"), nil + }, mailfilter.WithDecisionAt(mailfilter.DecisionAtMailFrom)) +} diff --git a/integration/tests/body/add.testcase b/integration/tests/body/add.testcase new file mode 100644 index 0000000..6b37685 --- /dev/null +++ b/integration/tests/body/add.testcase @@ -0,0 +1,9 @@ +FROM +BODY +one +. +DECISION ACCEPT +BODY +one +two +. diff --git a/integration/tests/body/test.go b/integration/tests/body/test.go new file mode 100644 index 0000000..4931ad4 --- /dev/null +++ b/integration/tests/body/test.go @@ -0,0 +1,29 @@ +//go:build: auth-no + +package main + +import ( + "bytes" + "context" + "io" + + "github.com/d--j/go-milter/integration" + "github.com/d--j/go-milter/mailfilter" +) + +func main() { + integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) { + switch trx.MailFrom.Addr { + case "add@example.com": + b, err := io.ReadAll(trx.Body()) + if err != nil { + return nil, err + } + b = append(b, "two\r\n"...) + trx.ReplaceBody(bytes.NewReader(b)) + default: + return mailfilter.CustomErrorResponse(500, "unknown mail from"), nil + } + return mailfilter.Accept, nil + }) +} diff --git a/integration/tests/header/add-first.testcase b/integration/tests/header/add-first.testcase new file mode 100644 index 0000000..178156c --- /dev/null +++ b/integration/tests/header/add-first.testcase @@ -0,0 +1,19 @@ +FROM +HEADER +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. +DECISION ACCEPT +HEADER +X-First1: Test +X-First2: Test +Received: placeholder +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. diff --git a/integration/tests/header/add-middle.testcase b/integration/tests/header/add-middle.testcase new file mode 100644 index 0000000..e9b2931 --- /dev/null +++ b/integration/tests/header/add-middle.testcase @@ -0,0 +1,19 @@ +FROM +HEADER +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. +DECISION ACCEPT +HEADER +Received: placeholder +From: <> +To: +X-Middle1: Test +X-Middle2: Test +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. diff --git a/integration/tests/header/add.testcase b/integration/tests/header/add.testcase new file mode 100644 index 0000000..5a9e7ce --- /dev/null +++ b/integration/tests/header/add.testcase @@ -0,0 +1,19 @@ +FROM +HEADER +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. +DECISION ACCEPT +HEADER +Received: placeholder +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +X-ADD1: Test +X-ADD2: Test +. diff --git a/integration/tests/header/del.testcase b/integration/tests/header/del.testcase new file mode 100644 index 0000000..b552fec --- /dev/null +++ b/integration/tests/header/del.testcase @@ -0,0 +1,16 @@ +FROM +HEADER +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. +DECISION ACCEPT +HEADER +Received: placeholder +From: <> +To: +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. diff --git a/integration/tests/header/multi.testcase b/integration/tests/header/multi.testcase new file mode 100644 index 0000000..bfb7036 --- /dev/null +++ b/integration/tests/header/multi.testcase @@ -0,0 +1,21 @@ +FROM +HEADER +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. +DECISION ACCEPT +HEADER +X-First1: Test +X-First2: Test +Received: placeholder +From: <> +To: +X-Before-DATE: Test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +X-ADD1: Test +X-ADD2: Test +. diff --git a/integration/tests/header/subject.testcase b/integration/tests/header/subject.testcase new file mode 100644 index 0000000..aca706c --- /dev/null +++ b/integration/tests/header/subject.testcase @@ -0,0 +1,17 @@ +FROM +HEADER +From: <> +To: +Subject: test +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. +DECISION ACCEPT +HEADER +Received: placeholder +From: <> +To: +Subject: changed +Date: Fri, 10 Mar 2023 23:29:35 +0000 (UTC) +Message-ID: +. diff --git a/integration/tests/header/test.go b/integration/tests/header/test.go new file mode 100644 index 0000000..a4c14ca --- /dev/null +++ b/integration/tests/header/test.go @@ -0,0 +1,69 @@ +//go:build: auth-no + +package main + +import ( + "context" + "io" + "log" + + "github.com/d--j/go-milter/integration" + "github.com/d--j/go-milter/mailfilter" +) + +func main() { + integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) { + switch trx.MailFrom.Addr { + case "add@example.com": + trx.Headers.Add("X-ADD1", "Test") + trx.Headers.Add("X-ADD2", "Test") + case "add-first@example.com": + f := trx.Headers.Fields() + f.Next() + f.InsertBefore("X-First1", "Test") + f.InsertBefore("X-First2", "Test") + case "add-middle@example.com": + f := trx.Headers.Fields() + for f.Next() { + if f.CanonicalKey() == "Subject" { + f.InsertBefore("X-Middle1", "Test") + f.InsertBefore("X-Middle2", "Test") + break + } + } + case "subject@example.com": + trx.Headers.SetSubject("changed") + case "del@example.com": + f := trx.Headers.Fields() + for f.Next() { + if f.CanonicalKey() == "Subject" { + f.Del() + break + } + } + case "multi@example.com": + f := trx.Headers.Fields() + first := true + for f.Next() { + if first { + f.InsertBefore("X-First1", "Test") + f.InsertBefore("X-First2", "Test") + first = false + } + if f.CanonicalKey() == "Subject" { + f.Del() + } + if f.CanonicalKey() == "Date" { + f.InsertBefore("X-Before-DATE", "Test") + } + } + trx.Headers.Add("X-ADD1", "Test") + trx.Headers.Add("X-ADD2", "Test") + default: + return mailfilter.CustomErrorResponse(500, "unknown mail from"), nil + } + b, _ := io.ReadAll(trx.Headers.Reader()) + log.Printf("from %s header %q", trx.MailFrom.Addr, string(b)) + return mailfilter.Accept, nil + }) +} diff --git a/integration/tests/mail-from/accept.testcase b/integration/tests/mail-from/accept.testcase new file mode 100644 index 0000000..54f50d3 --- /dev/null +++ b/integration/tests/mail-from/accept.testcase @@ -0,0 +1,3 @@ +FROM +DECISION ACCEPT +FROM * diff --git a/integration/tests/mail-from/custom.testcase b/integration/tests/mail-from/custom.testcase new file mode 100644 index 0000000..ee52cd2 --- /dev/null +++ b/integration/tests/mail-from/custom.testcase @@ -0,0 +1,3 @@ +FROM +DECISION CUSTOM@FROM +555 custom diff --git a/integration/tests/mail-from/discard.testcase b/integration/tests/mail-from/discard.testcase new file mode 100644 index 0000000..869c51f --- /dev/null +++ b/integration/tests/mail-from/discard.testcase @@ -0,0 +1,2 @@ +FROM +DECISION DISCARD-OR-QUARANTINE diff --git a/integration/tests/mail-from/quarantine.testcase b/integration/tests/mail-from/quarantine.testcase new file mode 100644 index 0000000..ecaf159 --- /dev/null +++ b/integration/tests/mail-from/quarantine.testcase @@ -0,0 +1,2 @@ +FROM +DECISION DISCARD-OR-QUARANTINE diff --git a/integration/tests/mail-from/reject.testcase b/integration/tests/mail-from/reject.testcase new file mode 100644 index 0000000..de825de --- /dev/null +++ b/integration/tests/mail-from/reject.testcase @@ -0,0 +1,2 @@ +FROM +DECISION REJECT@FROM diff --git a/integration/tests/mail-from/temp-fail.testcase b/integration/tests/mail-from/temp-fail.testcase new file mode 100644 index 0000000..f8170a2 --- /dev/null +++ b/integration/tests/mail-from/temp-fail.testcase @@ -0,0 +1,2 @@ +FROM +DECISION TEMPFAIL@FROM diff --git a/integration/tests/mail-from/test.go b/integration/tests/mail-from/test.go new file mode 100644 index 0000000..8520a66 --- /dev/null +++ b/integration/tests/mail-from/test.go @@ -0,0 +1,31 @@ +//go:build: auth-no + +package main + +import ( + "context" + + "github.com/d--j/go-milter/integration" + "github.com/d--j/go-milter/mailfilter" +) + +func main() { + integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) { + if trx.MailFrom.Addr == "temp-fail@example.com" { + return mailfilter.TempFail, nil + } + if trx.MailFrom.Addr == "reject@example.com" { + return mailfilter.Reject, nil + } + if trx.MailFrom.Addr == "discard@example.com" { + return mailfilter.Discard, nil + } + if trx.MailFrom.Addr == "custom@example.com" { + return mailfilter.CustomErrorResponse(555, "custom"), nil + } + if trx.MailFrom.Addr == "quarantine@example.com" { + return mailfilter.QuarantineResponse("test"), nil + } + return mailfilter.Accept, nil + }, mailfilter.WithDecisionAt(mailfilter.DecisionAtMailFrom)) +} diff --git a/integration/tests/rcpt-to/accept.testcase b/integration/tests/rcpt-to/accept.testcase new file mode 100644 index 0000000..114ed8e --- /dev/null +++ b/integration/tests/rcpt-to/accept.testcase @@ -0,0 +1,3 @@ +TO +DECISION ACCEPT +TO * diff --git a/integration/tests/rcpt-to/custom.testcase b/integration/tests/rcpt-to/custom.testcase new file mode 100644 index 0000000..d38663f --- /dev/null +++ b/integration/tests/rcpt-to/custom.testcase @@ -0,0 +1,3 @@ +TO +DECISION CUSTOM@* +555 custom diff --git a/integration/tests/rcpt-to/discard.testcase b/integration/tests/rcpt-to/discard.testcase new file mode 100644 index 0000000..949aa7e --- /dev/null +++ b/integration/tests/rcpt-to/discard.testcase @@ -0,0 +1,2 @@ +TO +DECISION DISCARD-OR-QUARANTINE diff --git a/integration/tests/rcpt-to/quarantine.testcase b/integration/tests/rcpt-to/quarantine.testcase new file mode 100644 index 0000000..00cc32f --- /dev/null +++ b/integration/tests/rcpt-to/quarantine.testcase @@ -0,0 +1,2 @@ +TO +DECISION DISCARD-OR-QUARANTINE diff --git a/integration/tests/rcpt-to/reject.testcase b/integration/tests/rcpt-to/reject.testcase new file mode 100644 index 0000000..a1bbdc8 --- /dev/null +++ b/integration/tests/rcpt-to/reject.testcase @@ -0,0 +1,2 @@ +TO +DECISION REJECT@* diff --git a/integration/tests/rcpt-to/temp-fail.testcase b/integration/tests/rcpt-to/temp-fail.testcase new file mode 100644 index 0000000..4a1583b --- /dev/null +++ b/integration/tests/rcpt-to/temp-fail.testcase @@ -0,0 +1,2 @@ +TO +DECISION TEMPFAIL@* diff --git a/integration/tests/rcpt-to/test.go b/integration/tests/rcpt-to/test.go new file mode 100644 index 0000000..4da98ac --- /dev/null +++ b/integration/tests/rcpt-to/test.go @@ -0,0 +1,33 @@ +//go:build: auth-no + +package main + +import ( + "context" + + "github.com/d--j/go-milter/integration" + "github.com/d--j/go-milter/mailfilter" +) + +func main() { + integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) { + if trx.HasRcptTo("temp-fail@example.com") { + return mailfilter.TempFail, nil + } + if trx.HasRcptTo("reject@example.com") { + return mailfilter.Reject, nil + } + if trx.HasRcptTo("discard@example.com") { + return mailfilter.Discard, nil + } + if trx.HasRcptTo("custom@example.com") { + return mailfilter.CustomErrorResponse(555, "custom"), nil + } + if trx.HasRcptTo("quarantine@example.com") { + return mailfilter.QuarantineResponse("test"), nil + } + return mailfilter.Accept, nil + // the decision is done at the DATA command but the mock server sends the DATA response before we can intercept + // so the testcases allow the rejection at any step, not only at "DATA". + }, mailfilter.WithDecisionAt(mailfilter.DecisionAtData)) +} diff --git a/internal/wire/wire.go b/internal/wire/wire.go index 77e2c73..a69c8e0 100644 --- a/internal/wire/wire.go +++ b/internal/wire/wire.go @@ -115,9 +115,7 @@ func WritePacket(conn net.Conn, msg *Message, timeout time.Duration) error { return fmt.Errorf("milter: cannot write %d bytes in one message", length) } - header := [5]byte{0, 0, 0, 0, byte(msg.Code)} - binary.BigEndian.PutUint32(header[0:], uint32(length)) - _, err := conn.Write(header[:]) + _, err := conn.Write([]byte{byte(length >> 24), byte(length >> 16), byte(length >> 8), byte(length), byte(msg.Code)}) if err != nil { return err } diff --git a/mailfilter/backend.go b/mailfilter/backend.go index 6a2bf87..459d699 100644 --- a/mailfilter/backend.go +++ b/mailfilter/backend.go @@ -1,12 +1,12 @@ package mailfilter import ( + "context" "fmt" "strings" "time" "github.com/d--j/go-milter" - "golang.org/x/net/context" ) type backend struct { diff --git a/mailfilter/decision.go b/mailfilter/decision.go index 157f41f..3ce7a43 100644 --- a/mailfilter/decision.go +++ b/mailfilter/decision.go @@ -44,3 +44,21 @@ func CustomErrorResponse(code uint16, reason string) Decision { reason: reason, } } + +type quarantineResponse struct { + reason string +} + +func (c quarantineResponse) getCode() uint16 { + return 250 +} + +func (c quarantineResponse) getReason() string { + return "accept" +} + +func QuarantineResponse(reason string) Decision { + return &quarantineResponse{ + reason: reason, + } +} diff --git a/mailfilter/decision_test.go b/mailfilter/decision_test.go index b49fc6f..a1a4ffc 100644 --- a/mailfilter/decision_test.go +++ b/mailfilter/decision_test.go @@ -109,3 +109,30 @@ func Test_decision_getReason(t *testing.T) { }) } } + +func TestQuarantineResponse(t *testing.T) { + type args struct { + reason string + } + tests := []struct { + name string + args args + want Decision + }{ + {"works", args{"reason"}, &quarantineResponse{reason: "reason"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := QuarantineResponse(tt.args.reason) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("QuarantineResponse() = %v, want %v", got, tt.want) + } + if got.getCode() != Accept.getCode() { + t.Errorf("QuarantineResponse().getCode() = %v, want %v", got.getCode(), Accept.getCode()) + } + if got.getReason() != Accept.getReason() { + t.Errorf("QuarantineResponse().getReason() = %v, want %v", got.getReason(), Accept.getReason()) + } + }) + } +} diff --git a/mailfilter/header.go b/mailfilter/header.go index 0c79817..3fabeb6 100644 --- a/mailfilter/header.go +++ b/mailfilter/header.go @@ -350,11 +350,9 @@ func diffHeaderFieldsMiddle(orig []*headerField, changed []*headerField, index i if o.index < 0 { panic("internal structure error: all elements in orig need to have an index bigger than -1: do not completely replace transaction.Headers – use its methods to alter it") } - found := false - // find o in changed + // find o.index in changed for i, c := range changed { if c.index == o.index { - found = true index = o.index changedI = i for i = 0; i < changedI; i++ { @@ -386,21 +384,9 @@ func diffHeaderFieldsMiddle(orig []*headerField, changed []*headerField, index i changedI++ break } else if c.index > o.index { - break + panic("internal structure error: index of original was not found in changed: do not completely replace transaction.Headers – use its methods to alter it") } } - // if o not in changed we need to delete it - if !found { - diffs = append(diffs, headerFieldDiff{ - kind: kindChange, - field: &headerField{ - index: o.index, - canonicalKey: o.canonicalKey, - raw: o.key() + ":", - }, - index: o.index, - }) - } // we only consumed the first element of orig index++ restDiffs := diffHeaderFields(orig[1:], changed[changedI:], index) @@ -447,6 +433,7 @@ func diffHeaderFields(orig []*headerField, changed []*headerField, index int) (d } type headerOp struct { + Kind int Index int Name string Value string @@ -455,7 +442,7 @@ type headerOp struct { // calculateHeaderModifications finds differences between orig and changed. // The differences are expressed as change and insert operations – to be mapped to milter modification actions. // Deletions are changes to an empty value. -func calculateHeaderModifications(orig *Header, changed *Header) (changeOps []headerOp, insertOps []headerOp) { +func calculateHeaderModifications(orig *Header, changed *Header) (changeInsertOps []headerOp, addOps []headerOp) { origFields := orig.Fields() origLen := origFields.Len() origIndexByKeyCounter := make(map[string]int) @@ -468,20 +455,34 @@ func calculateHeaderModifications(orig *Header, changed *Header) (changeOps []he for _, diff := range diffs { switch diff.kind { case kindInsert: - insertOps = append(insertOps, headerOp{ - Index: diff.index + 1, - Name: diff.field.key(), - Value: diff.field.value(), - }) + idx := diff.index + 1 + if idx > 0 { + idx += 1 + } + if idx-1 >= origLen { + addOps = append(addOps, headerOp{ + Index: idx, + Name: diff.field.key(), + Value: diff.field.value(), + }) + } else { + changeInsertOps = append(changeInsertOps, headerOp{ + Kind: kindInsert, + Index: idx, + Name: diff.field.key(), + Value: diff.field.value(), + }) + } case kindChange: if diff.index < origLen { - changeOps = append(changeOps, headerOp{ + changeInsertOps = append(changeInsertOps, headerOp{ + Kind: kindChange, Index: origIndexByKey[diff.index], Name: diff.field.key(), Value: diff.field.value(), }) - } else { // should not happen but just make inserts out of it - insertOps = append(insertOps, headerOp{ + } else { // should not happen but just make adds out of it + addOps = append(addOps, headerOp{ Index: diff.index + 1, Name: diff.field.key(), Value: diff.field.value(), diff --git a/mailfilter/header_test.go b/mailfilter/header_test.go index 5bbb85c..4c40634 100644 --- a/mailfilter/header_test.go +++ b/mailfilter/header_test.go @@ -958,8 +958,8 @@ func TestHeader_addRaw(t *testing.T) { args args want []*headerField }{ - {"works", nil, args{key: "TEST", raw: "TEST: value"}, []*headerField{&headerField{canonicalKey: "Test", raw: "TEST: value"}}}, - {"empty-is-ok", nil, args{key: "TEST", raw: "TEST:"}, []*headerField{&headerField{canonicalKey: "Test", raw: "TEST:"}}}, + {"works", nil, args{key: "TEST", raw: "TEST: value"}, []*headerField{{canonicalKey: "Test", raw: "TEST: value"}}}, + {"empty-is-ok", nil, args{key: "TEST", raw: "TEST:"}, []*headerField{{canonicalKey: "Test", raw: "TEST:"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1092,37 +1092,37 @@ func Test_calculateHeaderModifications(t *testing.T) { changed *Header } tests := []struct { - name string - args args - wantChangeOps []headerOp - wantInsertOps []headerOp + name string + args args + wantChangeInsertOps []headerOp + wantAddOps []headerOp }{ {"equal", args{orig, orig}, nil, nil}, - {"add-one", args{orig, addOne}, nil, []headerOp{{Index: 4, Name: "X-Test", Value: " 1"}}}, - {"add-one-in-front", args{orig, addOneInFront}, nil, []headerOp{{Index: 0, Name: "X-Test", Value: " 1"}}}, + {"add-one", args{orig, addOne}, nil, []headerOp{{Index: 5, Name: "X-Test", Value: " 1"}}}, + {"add-one-in-front", args{orig, addOneInFront}, []headerOp{{Kind: kindInsert, Index: 0, Name: "X-Test", Value: " 1"}}, nil}, {"complex", args{orig, complexChanges}, []headerOp{ - {Index: 1, Name: "subject", Value: " changed"}, - {Index: 1, Name: "DATE", Value: ""}, + {Kind: kindInsert, Index: 0, Name: "X-Test", Value: " 1"}, + {Kind: kindInsert, Index: 2, Name: "X-Test", Value: " 1"}, + {Kind: kindInsert, Index: 2, Name: "X-Test", Value: " 1"}, + {Kind: kindInsert, Index: 3, Name: "X-Test", Value: " 1"}, + {Kind: kindInsert, Index: 3, Name: "X-Test", Value: " 1"}, + {Kind: kindChange, Index: 1, Name: "subject", Value: " changed"}, + {Kind: kindInsert, Index: 4, Name: "X-Test", Value: " 1"}, + {Kind: kindInsert, Index: 4, Name: "X-Test", Value: " 1"}, + {Kind: kindChange, Index: 1, Name: "DATE", Value: ""}, }, []headerOp{ - {Index: 0, Name: "X-Test", Value: " 1"}, - {Index: 1, Name: "X-Test", Value: " 1"}, - {Index: 1, Name: "X-Test", Value: " 1"}, - {Index: 2, Name: "X-Test", Value: " 1"}, - {Index: 2, Name: "X-Test", Value: " 1"}, - {Index: 3, Name: "X-Test", Value: " 1"}, - {Index: 3, Name: "X-Test", Value: " 1"}, - {Index: 4, Name: "X-Test", Value: " 1"}, - {Index: 4, Name: "X-Test", Value: " 1"}, + {Index: 5, Name: "X-Test", Value: " 1"}, + {Index: 5, Name: "X-Test", Value: " 1"}, }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotChangeOps, gotInsertOps := calculateHeaderModifications(tt.args.orig, tt.args.changed) - if !reflect.DeepEqual(gotChangeOps, tt.wantChangeOps) { - t.Errorf("calculateHeaderModifications() gotChangeOps = %+v, want %+v", gotChangeOps, tt.wantChangeOps) + gotChangeInsertOps, gotAddOps := calculateHeaderModifications(tt.args.orig, tt.args.changed) + if !reflect.DeepEqual(gotChangeInsertOps, tt.wantChangeInsertOps) { + t.Errorf("calculateHeaderModifications() gotChangeInsertOps = %+v, want %+v", gotChangeInsertOps, tt.wantChangeInsertOps) } - if !reflect.DeepEqual(gotInsertOps, tt.wantInsertOps) { - t.Errorf("calculateHeaderModifications() gotInsertOps = %+v, want %+v", gotInsertOps, tt.wantInsertOps) + if !reflect.DeepEqual(gotAddOps, tt.wantAddOps) { + t.Errorf("calculateHeaderModifications() gotAddOps = %+v, want %+v", gotAddOps, tt.wantAddOps) } }) } diff --git a/mailfilter/mailfilter.go b/mailfilter/mailfilter.go index 3f33204..cf1108c 100644 --- a/mailfilter/mailfilter.go +++ b/mailfilter/mailfilter.go @@ -100,6 +100,7 @@ func New(network, address string, decision DecisionModificationFunc, opts ...Opt opts: resolvedOptions, decision: decision, leadingSpace: protocol&milter.OptHeaderLeadingSpace != 0, + transaction: &Transaction{}, } }), milter.WithActions(actions), diff --git a/mailfilter/transaction.go b/mailfilter/transaction.go index 4a27db6..66875a5 100644 --- a/mailfilter/transaction.go +++ b/mailfilter/transaction.go @@ -64,14 +64,15 @@ type Transaction struct { // Only populated if [WithDecisionAt] is bigger than [DecisionAtData]. Headers *Header - hasDecision bool - decision Decision - decisionErr error - headers *Header - body *os.File - mailFrom MailFrom - rcptTos []RcptTo - replacementBody io.Reader + hasDecision bool + decision Decision + decisionErr error + headers *Header + body *os.File + mailFrom MailFrom + rcptTos []RcptTo + replacementBody io.Reader + quarantineReason *string } func (t *Transaction) cleanup() { @@ -79,14 +80,8 @@ func (t *Transaction) cleanup() { t.headers = nil t.RcptTos = nil t.rcptTos = nil - if t.replacementBody != nil { - if closer, ok := t.replacementBody.(io.Closer); ok { - if err := closer.Close(); err != nil { - milter.LogWarning("error while closing replacement body: %s", err) - } - } - t.replacementBody = nil - } + t.quarantineReason = nil + t.closeReplacementBody() if t.body != nil { _ = t.body.Close() _ = os.Remove(t.body.Name()) @@ -134,6 +129,12 @@ func (t *Transaction) makeDecision(ctx context.Context, decide DecisionModificat d, err := decide(ctx, t) // save decision t.hasDecision = true + // if QuarantineResponse was used, replace it with Accept and record the reason, + // so we can later send a quarantine modification action + if qR, ok := d.(*quarantineResponse); ok { + t.quarantineReason = &qR.reason + d = Accept + } t.decision = d t.decisionErr = err } @@ -143,6 +144,9 @@ func (t *Transaction) hasModifications() bool { if !t.hasDecision { return false } + if t.quarantineReason != nil { + return true + } if t.mailFrom.Addr != t.MailFrom.Addr || t.mailFrom.Args != t.MailFrom.Args { return true } @@ -187,34 +191,44 @@ func (t *Transaction) sendModifications(m *milter.Modifier) error { return err } } - changeOps, insertOps := calculateHeaderModifications(t.headers, t.Headers) - for _, op := range changeOps { - if err := m.ChangeHeader(op.Index, op.Name, op.Value); err != nil { - return err - } - } - // apply insert operations in reverse for the indexes to be correct - if len(insertOps) > 0 { - for i := len(insertOps) - 1; i > -1; i-- { - op := insertOps[i] + changeInsertOps, addOps := calculateHeaderModifications(t.headers, t.Headers) + // apply change/insert operations in reverse for the indexes to be correct + for i := len(changeInsertOps) - 1; i > -1; i-- { + op := changeInsertOps[i] + if op.Kind == kindInsert { if err := m.InsertHeader(op.Index, op.Name, op.Value); err != nil { return err } + } else { + if err := m.ChangeHeader(op.Index, op.Name, op.Value); err != nil { + return err + } + } + } + for _, op := range addOps { + // Sendmail has headers in its envelop headers list that it does not send to the milter. + // But the *do* count to the insert index?! So for sendmail we cannot really add a header at a specific position. + // (Other than beginning, that is index 0). + // We add the arbitrary number 100 to the index so that we skip any and all "hidden" sendmail headers when we + // want to insert at the end of the header list. + // We do not use m.AddHeader since that also is not guaranteed to add the header at the end… + if err := m.InsertHeader(op.Index+len(changeInsertOps)+100, op.Name, op.Value); err != nil { + return err } } if t.replacementBody != nil { defer func() { - if closer, ok := t.replacementBody.(io.Closer); ok { - if err := closer.Close(); err != nil { - milter.LogWarning("error while closing replacement body: %s", err) - } - } - t.replacementBody = nil + t.closeReplacementBody() }() if err := m.ReplaceBody(t.replacementBody); err != nil { return err } } + if t.quarantineReason != nil { + if err := m.Quarantine(*t.quarantineReason); err != nil { + return err + } + } return nil } @@ -304,5 +318,17 @@ func (t *Transaction) Body() io.ReadSeeker { // ReplaceBody replaces the body of the current message with the contents // of the [io.Reader] r. func (t *Transaction) ReplaceBody(r io.Reader) { + t.closeReplacementBody() t.replacementBody = r } + +func (t *Transaction) closeReplacementBody() { + if t.replacementBody != nil { + if closer, ok := t.replacementBody.(io.Closer); ok { + if err := closer.Close(); err != nil { + milter.LogWarning("error while closing replacement body: %s", err) + } + } + t.replacementBody = nil + } +} diff --git a/mailfilter/transaction_test.go b/mailfilter/transaction_test.go index 8918226..2f7eecd 100644 --- a/mailfilter/transaction_test.go +++ b/mailfilter/transaction_test.go @@ -215,7 +215,7 @@ func TestTransaction_sendModifications(t1 *testing.T) { trx.Headers.Add("X-Test", "1") return Accept, nil }, []*wire.Message{ - mod(wire.ActInsertHeader, []byte("\u0000\u0000\u0000\u0003X-Test\u0000 1\u0000")), + mod(wire.ActInsertHeader, []byte("\u0000\u0000\u0000\x68X-Test\u0000 1\u0000")), }, false}, {"prepend-header", func(_ context.Context, trx *Transaction) (Decision, error) { f := trx.Headers.Fields() @@ -247,6 +247,15 @@ func TestTransaction_sendModifications(t1 *testing.T) { ctx.Value("s").(*mockSession).WritePacket = writeErr return Accept, nil }, nil, true}, + {"quarantine", func(ctx context.Context, trx *Transaction) (Decision, error) { + return QuarantineResponse("test"), nil + }, []*wire.Message{ + mod(wire.ActQuarantine, []byte("test\u0000")), + }, false}, + {"quarantine-err", func(ctx context.Context, trx *Transaction) (Decision, error) { + ctx.Value("s").(*mockSession).WritePacket = writeErr + return QuarantineResponse("test"), nil + }, nil, true}, } for _, tt := range tests { t1.Run(tt.name, func(t1 *testing.T) { diff --git a/modifier.go b/modifier.go index bd81ab1..0c11f94 100644 --- a/modifier.go +++ b/modifier.go @@ -114,19 +114,15 @@ type ModifyAction struct { // Index is 1-based. // // If Type = ActionChangeHeader the index is per canonical value of HdrName. - // E.g. HeaderIndex = 3 and HdrName = "DKIM-Signature" mean "change third DKIM-Signature field". + // E.g. HeaderIndex = 3 and HdrName = "DKIM-Signature" mean "change third field with the canonical header name Dkim-Signature". // Order is the same as of HeaderField calls. // // If Type = ActionInsertHeader the index is global to all headers, 1-based and means "insert after the HeaderIndex header". // A HeaderIndex of 0 has the special meaning "at the very beginning". // - // Deleted headers (Type = ActionChangeHeader and HeaderValue == "") do not change the indexes of the other headers. - // They will be skipped by the MTA but still occupy their place in the header list of the MTA. - // - // In both cases when you provide an index that is bigger than allowed the header gets added at the very end of the header list. - // This is NOT always semantically equal to Type == ActionAddHeader. - // Type == ActionAddHeader may actually replace an existing header instead of adding a new one. - // This will only happen with MTA generated headers besides "Received", "X400-Received", "Via" and "Mail-From". + // Deleted headers (Type = ActionChangeHeader and HeaderValue == "") may change the indexes of the other headers. + // Postfix MTA removes the header from the linked list (and thus change the indexes of headers coming after the deleted header). + // Sendmail on the other hand will only mark the header as deleted. HeaderIndex uint32 // Header field name to be added/changed if Type == ActionAddHeader or @@ -327,7 +323,23 @@ func (m *Modifier) ReplaceBody(r io.Reader) error { return scanner.Err() } +// Quarantine a message by giving a reason to hold it +func (m *Modifier) Quarantine(reason string) error { + if m.actions&OptQuarantine == 0 { + return ErrModificationNotAllowed + } + return m.writePacket(newResponse(wire.Code(wire.ActQuarantine), []byte(reason+"\x00")).Response()) +} + // AddHeader appends a new email message header to the message +// +// Unfortunately when interacting with Sendmail it is not guaranteed that the header +// will be added at the end. If Sendmail has a (maybe deleted) header of the same name +// in the list of headers, this header will be altered/re-used instead of adding a new +// header at the end. +// +// If you always want to add the header at the very end you need to use InsertHeader with +// a very high index. func (m *Modifier) AddHeader(name, value string) error { if m.actions&OptAddHeader == 0 { return ErrModificationNotAllowed @@ -340,16 +352,10 @@ func (m *Modifier) AddHeader(name, value string) error { return m.writePacket(newResponse(wire.Code(wire.ActAddHeader), buffer.Bytes()).Response()) } -// Quarantine a message by giving a reason to hold it -func (m *Modifier) Quarantine(reason string) error { - if m.actions&OptQuarantine == 0 { - return ErrModificationNotAllowed - } - return m.writePacket(newResponse(wire.Code(wire.ActQuarantine), []byte(reason+"\x00")).Response()) -} - // ChangeHeader replaces the header at the specified position with a new one. -// The index is per name. To delete a header pass an empty value. +// The index is per canonical name and one-based. To delete a header pass an empty value. +// If the index is bigger than there are headers with that name, then ChangeHeader will actually +// add a new header at the end of the header list (With the same semantic as AddHeader). func (m *Modifier) ChangeHeader(index int, name, value string) error { if m.actions&OptChangeHeader == 0 { return ErrModificationNotAllowed @@ -365,8 +371,12 @@ func (m *Modifier) ChangeHeader(index int, name, value string) error { return m.writePacket(newResponse(wire.Code(wire.ActChangeHeader), buffer.Bytes()).Response()) } -// InsertHeader inserts the header at the specified position -// index is 1 based. The index 0 means at the very beginning. +// InsertHeader inserts the header at the specified position. +// index is one-based. The index 0 means at the very beginning. +// +// Unfortunately when interacting with Sendmail the index is used to find the position +// in Sendmail's internal list of headers. Not all of those internal headers get send to the milter. +// Thus, you cannot really add a header at a specific position when the milter client is Sendmail. func (m *Modifier) InsertHeader(index int, name, value string) error { // Insert header does not have its own action flag if m.actions&OptChangeHeader == 0 && m.actions&OptAddHeader == 0 { diff --git a/session.go b/session.go index 20d57f9..2597ad7 100644 --- a/session.go +++ b/session.go @@ -336,7 +336,9 @@ func (m *serverSession) HandleMilterCommands() { // first do the negotiation msg, err := m.readPacket() if err != nil { - LogWarning("Error reading milter command: %v", err) + if err != io.EOF { + LogWarning("Error reading milter command: %v", err) + } return } resp, err := m.negotiate(msg, m.server.options.maxVersion, m.server.options.actions, m.server.options.protocol, m.server.options.negotiationCallback, m.server.options.macrosByStage, 0) @@ -414,6 +416,8 @@ func (m *serverSession) skipResponse(code wire.Code) bool { return m.protocolOption(OptNoUnknownReply) case wire.CodeEOH: return m.protocolOption(OptNoEOHReply) + case wire.CodeHeader: + return m.protocolOption(OptNoHeaderReply) case wire.CodeBody: return m.protocolOption(OptNoBodyReply) default: