letterbox

A simple SMTP to Maildir delivery agent
git clone https://www.brianlane.com/git/letterbox
Log | Files | Refs | README | LICENSE

commit b8fe21e6cb27a3b0ff8fa8c5f41dd6103de6ed0f
Author: Brian C. Lane <bcl@brianlane.com>
Date:   Mon, 23 Dec 2019 06:42:55 -0800

Very Simple SMTP to Maildir delivery is working

Diffstat:
AMakefile | 2++
Ago.mod | 11+++++++++++
Ago.sum | 19+++++++++++++++++++
Amain.go | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 253 insertions(+), 0 deletions(-)

diff --git a/Makefile b/Makefile @@ -0,0 +1,2 @@ +all: main.go + go build diff --git a/go.mod b/go.mod @@ -0,0 +1,11 @@ +module github.com/bcl/letterbox + +go 1.13 + +require ( + github.com/BurntSushi/toml v0.3.1 + github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625 + github.com/luksen/maildir v0.0.0-20180118131156-1859503b54bd + github.com/veandco/go-sdl2 v0.3.3 // indirect + golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,19 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/McKael/madon v2.3.0+incompatible h1:xMUA+Fy4saDV+8tN3MMnwJUoYWC//5Fy8LeOqJsRNIM= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625 h1:ckJgFhFWywOx+YLEMIJsTb+NV6NexWICk5+AMSuz3ss= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/luksen/maildir v0.0.0-20180118131156-1859503b54bd h1:RjDnqXEJasth7m8Z+okAKdNCAg+Kt0w+qMvyvqW/QCI= +github.com/luksen/maildir v0.0.0-20180118131156-1859503b54bd/go.mod h1:ZCFCeVAq3QI7TMtCH/6fr2sYqBCLeeGhda7tCQFC/m4= +github.com/veandco/go-sdl2 v0.3.3 h1:4/TirgB2MQ7oww3pM3Yfgf1YbChMlAQAmiCPe5koK0I= +github.com/veandco/go-sdl2 v0.3.3/go.mod h1:FB+kTpX9YTE+urhYiClnRzpOXbiWgaU3+5F2AB78DPg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f h1:kDxGY2VmgABOe55qheT/TFqUMtcTHnomIPS1iv3G4Ms= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go @@ -0,0 +1,221 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "github.com/BurntSushi/toml" + "github.com/bradfitz/go-smtpd/smtpd" + "github.com/luksen/maildir" + "log" + "net" + "path" + "strings" +) + +/* commandline flags */ +type cmdlineArgs struct { + Config string // Path to configuration file + Host string // Host IP or name to bind to + Port int // Port to bind to + Maildirs string // Path to top level of the user Maildirs +} + +/* commandline defaults */ +var cmdline = cmdlineArgs{ + Config: "letterbox.toml", + Host: "", + Port: 25, + Maildirs: "/var/spool/maildirs", +} + +/* parseArgs handles parsing the cmdline args and setting values in the global cmdline struct */ +func parseArgs() { + flag.StringVar(&cmdline.Config, "config", cmdline.Config, "Path to configutation file") + flag.StringVar(&cmdline.Host, "host", cmdline.Host, "Host IP or name to bind to") + flag.IntVar(&cmdline.Port, "port", cmdline.Port, "Port to bind to") + flag.StringVar(&cmdline.Maildirs, "maildirs", cmdline.Maildirs, "Path to the top level of the user Maildirs") + + flag.Parse() +} + +type letterboxConfig struct { + Hosts []string `toml:"hosts"` + Emails []string `toml:"emails"` +} + +var cfg letterboxConfig +var allowedHosts []net.IP +var allowedNetworks []*net.IPNet + +// reads a TOML configuration file and returns a slice of settings +/* + Example TOML file: + + hosts = ["192.168.101.0/24", "fozzy.brianlane.com", "192.168.103.15"] + emails = ["user@domain.com", "root@domain.com"] +*/ +func readConfig(filename string) (letterboxConfig, error) { + var config letterboxConfig + if _, err := toml.DecodeFile(filename, &config); err != nil { + return config, err + } + return config, nil +} + +type env struct { + rcpts []smtpd.MailAddress + destDirs []*maildir.Dir + deliveries []*maildir.Delivery + tmpfile string +} + +func (e *env) AddRecipient(rcpt smtpd.MailAddress) error { + if strings.HasPrefix(rcpt.Email(), "bad@") { + return errors.New("we don't send email to bad@") + } + // Check the recipients against the whitelist. Only append ones that pass + e.rcpts = append(e.rcpts, rcpt) + return nil +} + +func (e *env) BeginData() error { + if len(e.rcpts) == 0 { + return smtpd.SMTPError("554 5.5.1 Error: no valid recipients") + } + + for _, rcpt := range e.rcpts { + if !strings.Contains(rcpt.Email(), "@") { + log.Printf("Skipping recipient: %s", rcpt) + continue + } + // Eliminate anything that looks like a path + user := path.Base(path.Clean(strings.Split(rcpt.Email(), "@")[0])) + + // TODO filter recipients based on a whitelist + + // Add a new maildir for each recipient + userDir := maildir.Dir(path.Join(cmdline.Maildirs, user)) + if err := userDir.Create(); err != nil { + log.Printf("Error creating maildir for %s: %s", user, err) + return smtpd.SMTPError("450 Error: maildir unavailable") + } + e.destDirs = append(e.destDirs, &userDir) + delivery, err := userDir.NewDelivery() + if err != nil { + log.Printf("Error creating delivery for %s: %s", user, err) + return smtpd.SMTPError("450 Error: maildir unavailable") + } + e.deliveries = append(e.deliveries, delivery) + } + if len(e.deliveries) == 0 { + return smtpd.SMTPError("554 5.5.1 Error: no valid recipients") + } + + return nil +} + +func (e *env) Write(line []byte) error { + for _, delivery := range e.deliveries { + _, err := delivery.Write(line) + if err != nil { + // Delivery failed, need to close all the deliveries + e.Close() + return err + } + } + return nil +} + +// The server really should call this with error status from outside +func (e *env) Close() error { + for _, delivery := range e.deliveries { + err := delivery.Close() + if err != nil { + return err + } + } + return nil +} + +func onNewConnection(c smtpd.Connection) error { + client, _, err := net.SplitHostPort(c.Addr().String()) + if err != nil { + log.Printf("Problem parsing client address %s: %s", c.Addr().String(), err) + return errors.New("Problem parsing client address") + } + clientIP := net.ParseIP(client) + log.Printf("Connection from %s\n", clientIP.String()) + for _, h := range allowedHosts { + if h.Equal(clientIP) { + log.Printf("Connection from %s allowed by hosts\n", clientIP.String()) + return nil + } + } + + for _, n := range allowedNetworks { + if n.Contains(clientIP) { + log.Printf("Connection from %s allowed by network\n", clientIP.String()) + return nil + } + } + + log.Printf("Connection from %s rejected\n", clientIP.String()) + return errors.New("Client IP not allowed") +} + +func onNewMail(c smtpd.Connection, from smtpd.MailAddress) (smtpd.Envelope, error) { + log.Printf("letterbox: new mail from %q", from) + return &env{}, nil +} + +func main() { + parseArgs() + cfg, err := readConfig(cmdline.Config) + if err != nil { + log.Fatalf("Error reading config file %s: %s\n", cmdline.Config, err) + } + // Convert the hosts entries into IP and IPNet + for _, h := range cfg.Hosts { + // Does it look like a CIDR? + _, ipv4Net, err := net.ParseCIDR(h) + if err == nil { + allowedNetworks = append(allowedNetworks, ipv4Net) + continue + } + + // Does it look like an IP? + ip := net.ParseIP(h) + if ip != nil { + allowedHosts = append(allowedHosts, ip) + continue + } + + // Does it look like a hostname? + ips, err := net.LookupIP(h) + if err == nil { + for _, ip := range ips { + allowedHosts = append(allowedHosts, ip) + } + } + } + fmt.Printf("letterbox: %s:%d\n", cmdline.Host, cmdline.Port) + log.Println("Allowed Hosts") + for _, h := range allowedHosts { + log.Printf(" %s\n", h.String()) + } + log.Println("Allowed Networks") + for _, n := range allowedNetworks { + log.Printf(" %s\n", n.String()) + } + + s := &smtpd.Server{ + Addr: fmt.Sprintf("%s:%d", cmdline.Host, cmdline.Port), + OnNewConnection: onNewConnection, + OnNewMail: onNewMail, + } + err = s.ListenAndServe() + if err != nil { + log.Fatalf("ListenAndServe: %v", err) + } +}