Skip to content

Commit

Permalink
Merge pull request #2 from d--j/mailfilter
Browse files Browse the repository at this point in the history
feat: introduce mail filter abstraction
  • Loading branch information
d--j committed Mar 3, 2023
2 parents 2445c8b + 2fe0288 commit 9ef3191
Show file tree
Hide file tree
Showing 27 changed files with 3,927 additions and 122 deletions.
108 changes: 54 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,81 +6,81 @@

A Go library to write mail filters.

With this library you can write both the client (MTA/SMTP-Server) and server (milter filter)
in pure Go without sendmail's libmilter.

## Features

* With this library you can write both the client (MTA/SMTP-Server) and server (milter filter)
in pure Go without sendmail's libmilter.
* Easy wrapper of the milter protocol that abstracts away many milter protocol quirks
and lets you write mail filters with little effort.
* UTF-8 support
* IDNA support
* Client & Server support milter protocol version 6 with all features. E.g.:
* all milter events including DATA, UNKNOWN, ABORT and QUIT NEW CONNECTION
* 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.
* UTF-8 support

## Installation

```shell
go get -u github.com/d--j/go-milter
```

## Usage

The following example is a milter filter that adds `[⚠️EXTERNAL] ` to the subject of all messages of unauthenticated users.

See [GoDoc](https://godoc.org/github.com/d--j/go-milter/mailfilter) for more documentation and an example for a milter client or a raw milter server.

```go
package main

import (
"log"
"net"
"sync"
"context"
"flag"
"log"
"strings"

"github.com/d--j/go-milter"
"github.com/d--j/go-milter/mailfilter"
)

type ExampleBackend struct {
milter.NoOpMilter
}

func (b *ExampleBackend) RcptTo(rcptTo string, esmtpArgs string, m *milter.Modifier) (*milter.Response, error) {
// reject the mail when it goes to [email protected] and is a local delivery
if rcptTo == "[email protected]" && m.Macros.Get(milter.MacroRcptMailer) == "local" {
return milter.RejectWithCodeAndReason(550, "We do not like you\r\nvery much, please go away")
}
return milter.RespContinue, nil
}

func main() {
// create socket to listen on
socket, err := net.Listen("tcp4", "127.0.0.1:6785")
if err != nil {
log.Fatal(err)
}
defer socket.Close()

// define the backend, required actions, protocol options and macros we want
server := milter.NewServer(
milter.WithMilter(func() milter.Milter {
return &ExampleBackend{}
}),
milter.WithProtocol(milter.OptNoConnect|milter.OptNoHelo|milter.OptNoMailFrom|milter.OptNoBody|milter.OptNoHeaders|milter.OptNoEOH|milter.OptNoUnknown|milter.OptNoData),
milter.WithAction(milter.OptChangeFrom|milter.OptAddRcpt|milter.OptRemoveRcpt),
milter.WithMaroRequest(milter.StageRcpt, []milter.MacroName{milter.MacroRcptMailer}),
)
defer server.Close()

// start the milter
var wgDone sync.WaitGroup
wgDone.Add(1)
go func(socket net.Listener) {
if err := server.Serve(socket); err != nil {
log.Fatal(err)
}
wgDone.Done()
}(socket)

log.Printf("Started milter on %s:%s", socket.Addr().Network(), socket.Addr().String())

// quit when milter quits
wgDone.Wait()
// parse commandline arguments
var protocol, address string
flag.StringVar(&protocol, "proto", "tcp", "Protocol family (unix or tcp)")
flag.StringVar(&address, "addr", "127.0.0.1:10003", "Bind to address or unix domain socket")
flag.Parse()

// create and start the mail filter
mailFilter, err := mailfilter.New(protocol, address,
func(_ context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) {
// Reject message when it was sent to our SPAM trap
if trx.HasRcptTo("spam-trap@スパム.example.com") {
return mailfilter.CustomErrorResponse(550, "5.7.1 No thank you"), nil
}
// Prefix subject with [⚠️EXTERNAL] when user is not logged in
if trx.MailFrom.AuthenticatedUser() == "" {
subject, _ := trx.Headers.Subject()
if !strings.HasPrefix(subject, "[⚠️EXTERNAL] ") {
subject = "[⚠️EXTERNAL] " + subject
}
trx.Headers.SetSubject(subject)
}
return mailfilter.Accept, nil
},
// optimization: we do not need the body of the message for our decision
mailfilter.WithoutBody(),
)
if err != nil {
log.Fatal(err)
}
log.Printf("Started milter on %s:%s", mailFilter.Addr().Network(), mailFilter.Addr().String())

// wait for the mail filter to end
mailFilter.Wait()
}
```

See [![GoDoc](https://godoc.org/github.com/d--j/go-milter?status.svg)](https://godoc.org/github.com/d--j/go-milter) for more documentation and an example for a milter client.

## License

BSD 2-Clause
Expand Down
4 changes: 2 additions & 2 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ func (s *ClientSession) readAction(skipOk bool) (*Action, error) {
switch act.Type {
case ActionSkip:
if !skipOk {
return nil, fmt.Errorf("action read: unexpected skip message received (can only be received after SMFIC_BODY when SMFIP_SKIP was negotiated)")
return nil, fmt.Errorf("action read: unexpected skip message received (can only be received after SMFIC_RCPT, SMFIC_HEADER, SMFIC_BODY when SMFIP_SKIP was negotiated)")
}
case ActionReject:
act.SMTPCode = 550
Expand Down Expand Up @@ -507,7 +507,7 @@ func (s *ClientSession) Conn(hostname string, family ProtoFamily, port uint16, a
//
// It should be called once per milter session (from Client.Session to Close).
func (s *ClientSession) Helo(helo string) (*Action, error) {
if s.state != clientStateConnectCalled {
if s.state != clientStateConnectCalled && s.state != clientStateHeloCalled {
return nil, s.errorOut(fmt.Errorf("milter: in wrong state %d", s.state))
}

Expand Down
5 changes: 4 additions & 1 deletion client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,17 @@ func (mm *MockMilter) Header(name string, value string, m *Modifier) (*Response,
if mm.HdrMod != nil {
mm.HdrMod(m)
}
if mm.Hdr == nil {
mm.Hdr = make(nettextproto.MIMEHeader)
}
mm.Hdr.Add(name, value)
return mm.HdrResp, mm.HdrErr
}

func (mm *MockMilter) Headers(m *Modifier) (*Response, error) {
if mm.HdrsMod != nil {
mm.HdrsMod(m)
}
mm.Hdr = m.Headers
return mm.HdrsResp, mm.HdrsErr
}

Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ go 1.18

require (
github.com/emersion/go-message v0.16.0
golang.org/x/net v0.7.0
golang.org/x/text v0.7.0
)

require github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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-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/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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=
Expand Down
5 changes: 2 additions & 3 deletions log.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ func logWarning(format string, v ...interface{}) {
}

// LogWarning is called by this library when it wants to output a warning.
// Warnings can happen even when the library user did everything right (because the other end did something wrong
// but we recovered from it)
// Warnings can happen even when the library user did everything right (because the other end did something wrong)
//
// The default implementation uses log.Print to output the warning.
// The default implementation uses [log.Print] to output the warning.
// You can re-assign LogWarning to something more suitable for your application. But do not assign nil to it.
var LogWarning = logWarning
4 changes: 2 additions & 2 deletions macro.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ type Macros interface {
// A MacroBag is safe for concurrent use by multiple goroutines.
// It has special handling for the date related macros and can be copied.
//
// The zero value of MacroBag is invalid. Use NewMacroBag to create an empty MacroBag.
// The zero value of MacroBag is invalid. Use [NewMacroBag] to create an empty MacroBag.
type MacroBag struct {
macros map[MacroName]string
mutex sync.RWMutex
Expand Down Expand Up @@ -138,7 +138,7 @@ func (m *MacroBag) Set(name MacroName, value string) {
}

// Copy copies the macros to a new MacroBag.
// The time.Time values set by SetCurrentDate and SetHeaderDate do not get copied.
// The time.Time values set by [MacroBag.SetCurrentDate] and [MacroBag.SetHeaderDate] do not get copied.
func (m *MacroBag) Copy() *MacroBag {
m.mutex.Lock()
defer m.mutex.Unlock()
Expand Down
131 changes: 131 additions & 0 deletions mailfilter/addr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package mailfilter

import (
"strings"

"golang.org/x/net/idna"
)

// split an user@domain address into user and domain.
// Includes the input address as third array element to quickly check if splitting must be re-done
func split(addr string) []string {
at := strings.LastIndex(addr, "@")
if at < 0 {
return []string{addr, "", addr}
}

return []string{addr[:at], addr[at+1:], addr}
}

type addr struct {
Addr string
Args string
parts []string
asciiDomain string
unicodeDomain string
}

func (a *addr) initParts() {
if len(a.parts) != 3 || a.parts[2] != a.Addr {
a.parts = split(a.Addr)
a.asciiDomain = ""
a.unicodeDomain = ""
}
}

func (a *addr) Local() string {
a.initParts()
return a.parts[0]
}

func (a *addr) Domain() string {
a.initParts()
return a.parts[1]
}

func (a *addr) AsciiDomain() string {
domain := a.Domain()
if domain == "" {
return ""
}
if a.asciiDomain != "" {
return a.asciiDomain
}

asciiDomain, err := idna.Lookup.ToASCII(domain)
if err != nil {
a.asciiDomain = domain
return domain
}
a.asciiDomain = asciiDomain
return asciiDomain
}

func (a *addr) UnicodeDomain() string {
domain := a.Domain()
if domain == "" {
return ""
}
if a.unicodeDomain != "" {
return a.unicodeDomain
}

unicodeDomain, err := idna.Lookup.ToUnicode(domain)
if err != nil {
a.unicodeDomain = domain
return domain
}
a.unicodeDomain = unicodeDomain
return unicodeDomain
}

type MailFrom struct {
addr
transport string
authenticatedUser string
authenticationMethod string
}

func (m *MailFrom) Transport() string {
return m.transport
}

func (m *MailFrom) AuthenticatedUser() string {
return m.authenticatedUser
}

func (m *MailFrom) AuthenticationMethod() string {
return m.authenticationMethod
}

type RcptTo struct {
addr
transport string
}

func (r *RcptTo) Transport() string {
return r.transport
}

func calculateRcptToDiff(orig []RcptTo, changed []RcptTo) (deletions []RcptTo, additions []RcptTo) {
foundOrig := make(map[string]*RcptTo)
foundChanged := make(map[string]bool)
for _, r := range orig {
foundOrig[r.Addr] = &r
}
for _, r := range changed {
if o := foundOrig[r.Addr]; o == nil && !foundChanged[r.Addr] {
additions = append(additions, r)
} else if o != nil && o.Args != r.Args && !foundChanged[r.Addr] {
deletions = append(deletions, *o)
additions = append(additions, r)
}
foundChanged[r.Addr] = true
}
for _, r := range orig {
if !foundChanged[r.Addr] {
deletions = append(deletions, r)
}
}
return
}
Loading

0 comments on commit 9ef3191

Please sign in to comment.