letterbox

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

main.go (9164B)


      1 // letterbox - SMTP to Maildir delivery agent
      2 /*
      3 Copyright (c) 2019, Brian C. Lane <bcl@brianlane.com>
      4 All rights reserved.
      5 
      6 Redistribution and use in source and binary forms, with or without
      7 modification, are permitted provided that the following conditions are met:
      8 
      9 	* Redistributions of source code must retain the above copyright notice,
     10 	  this list of conditions and the following disclaimer.
     11 	* Redistributions in binary form must reproduce the above copyright notice,
     12 	  this list of conditions and the following disclaimer in the documentation
     13 	  and/or other materials provided with the distribution.
     14 	* Neither the name of the <ORGANIZATION> nor the names of its contributors
     15 	  may be used to endorse or promote products derived from this software without
     16 	  specific prior written permission.
     17 
     18 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
     19 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
     20 IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
     21 ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
     22 LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
     23 CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
     24 SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
     25 INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
     26 CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
     27 ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
     28 POSSIBILITY OF SUCH DAMAGE.
     29 */
     30 package main
     31 
     32 import (
     33 	"errors"
     34 	"flag"
     35 	"fmt"
     36 	"github.com/BurntSushi/toml"
     37 	"github.com/bradfitz/go-smtpd/smtpd"
     38 	"github.com/luksen/maildir"
     39 	"io"
     40 	"log"
     41 	"net"
     42 	"os"
     43 	"path"
     44 	"strings"
     45 )
     46 
     47 /* commandline flags */
     48 type cmdlineArgs struct {
     49 	Config   string // Path to configuration file
     50 	Host     string // Host IP or name to bind to
     51 	Port     int    // Port to bind to
     52 	Maildirs string // Path to top level of the user Maildirs
     53 	Logfile  string // Path to logfile
     54 	Debug    bool   // Log debugging information
     55 }
     56 
     57 /* commandline defaults */
     58 var cmdline = cmdlineArgs{
     59 	Config:   "letterbox.toml",
     60 	Host:     "127.0.0.1",
     61 	Port:     2525,
     62 	Maildirs: "/var/spool/maildirs",
     63 	Logfile:  "",
     64 	Debug:    false,
     65 }
     66 
     67 /* parseArgs handles parsing the cmdline args and setting values in the global cmdline struct */
     68 func parseArgs() {
     69 	flag.StringVar(&cmdline.Config, "config", cmdline.Config, "Path to configutation file")
     70 	flag.StringVar(&cmdline.Host, "host", cmdline.Host, "Host IP or name to bind to")
     71 	flag.IntVar(&cmdline.Port, "port", cmdline.Port, "Port to bind to")
     72 	flag.StringVar(&cmdline.Maildirs, "maildirs", cmdline.Maildirs, "Path to the top level of the user Maildirs")
     73 	flag.StringVar(&cmdline.Logfile, "log", cmdline.Logfile, "Path to logfile")
     74 	flag.BoolVar(&cmdline.Debug, "debug", cmdline.Debug, "Log debugging information")
     75 
     76 	flag.Parse()
     77 }
     78 
     79 // Only log if -debug has been passed to the program
     80 func logDebugf(format string, v ...interface{}) {
     81 	if cmdline.Debug {
     82 		log.Printf(format, v...)
     83 	}
     84 }
     85 
     86 type letterboxConfig struct {
     87 	Hosts  []string `toml:"hosts"`
     88 	Emails []string `toml:"emails"`
     89 }
     90 
     91 var cfg letterboxConfig
     92 var allowedHosts []net.IP
     93 var allowedNetworks []*net.IPNet
     94 
     95 // readConfig reads a TOML configuration file and returns a slice of settings
     96 /*
     97    Example TOML file:
     98 
     99    hosts = ["192.168.101.0/24", "fozzy.brianlane.com", "192.168.103.15"]
    100    emails = ["user@domain.com", "root@domain.com"]
    101 */
    102 func readConfig(r io.Reader) (letterboxConfig, error) {
    103 	var config letterboxConfig
    104 	if _, err := toml.DecodeReader(r, &config); err != nil {
    105 		return config, err
    106 	}
    107 	return config, nil
    108 }
    109 
    110 // parseHosts fills the global allowedHosts and allowedNetworks from the cfg.Hosts list
    111 func parseHosts() {
    112 	// Convert the hosts entries into IP and IPNet
    113 	for _, h := range cfg.Hosts {
    114 		// Does it look like a CIDR?
    115 		_, ipv4Net, err := net.ParseCIDR(h)
    116 		if err == nil {
    117 			allowedNetworks = append(allowedNetworks, ipv4Net)
    118 			continue
    119 		}
    120 
    121 		// Does it look like an IP?
    122 		ip := net.ParseIP(h)
    123 		if ip != nil {
    124 			allowedHosts = append(allowedHosts, ip)
    125 			continue
    126 		}
    127 
    128 		// Does it look like a hostname?
    129 		ips, err := net.LookupIP(h)
    130 		if err == nil {
    131 			allowedHosts = append(allowedHosts, ips...)
    132 		}
    133 	}
    134 }
    135 
    136 // smtpd.Envelope interface, with some extra data for letterbox delivery
    137 type env struct {
    138 	rcpts      []smtpd.MailAddress
    139 	destDirs   []*maildir.Dir
    140 	deliveries []*maildir.Delivery
    141 }
    142 
    143 // AddRecipient is called when RCPT TO is received
    144 // It checks the email against the whitelist and rejects it if it is not an exact match
    145 func (e *env) AddRecipient(rcpt smtpd.MailAddress) error {
    146 	// Match the recipient against the email whitelist
    147 	for _, user := range cfg.Emails {
    148 		if rcpt.Email() == user {
    149 			e.rcpts = append(e.rcpts, rcpt)
    150 			return nil
    151 		}
    152 	}
    153 	return errors.New("Recipient not in whitelist")
    154 }
    155 
    156 // BeginData is called when DATA is received
    157 // It sanitizes the revipient email and creates any missing maildirs
    158 func (e *env) BeginData() error {
    159 	if len(e.rcpts) == 0 {
    160 		return smtpd.SMTPError("554 5.5.1 Error: no valid recipients")
    161 	}
    162 
    163 	for _, rcpt := range e.rcpts {
    164 		if !strings.Contains(rcpt.Email(), "@") {
    165 			logDebugf("Skipping recipient: %s", rcpt)
    166 			continue
    167 		}
    168 		// Eliminate anything that looks like a path
    169 		user := path.Base(path.Clean(strings.Split(rcpt.Email(), "@")[0]))
    170 
    171 		// TODO reroute mail based on /etc/aliases
    172 
    173 		// Add a new maildir for each recipient
    174 		userDir := maildir.Dir(path.Join(cmdline.Maildirs, user))
    175 		if err := userDir.Create(); err != nil {
    176 			log.Printf("Error creating maildir for %s: %s", user, err)
    177 			return smtpd.SMTPError("450 Error: maildir unavailable")
    178 		}
    179 		e.destDirs = append(e.destDirs, &userDir)
    180 		delivery, err := userDir.NewDelivery()
    181 		if err != nil {
    182 			log.Printf("Error creating delivery for %s: %s", user, err)
    183 			return smtpd.SMTPError("450 Error: maildir unavailable")
    184 		}
    185 		e.deliveries = append(e.deliveries, delivery)
    186 	}
    187 	if len(e.deliveries) == 0 {
    188 		return smtpd.SMTPError("554 5.5.1 Error: no valid recipients")
    189 	}
    190 
    191 	return nil
    192 }
    193 
    194 // Write is called for each line of the email
    195 // It supports writing to multiple recipients at the same time.
    196 func (e *env) Write(line []byte) error {
    197 	for _, delivery := range e.deliveries {
    198 		_, err := delivery.Write(line)
    199 		if err != nil {
    200 			// Delivery failed, need to close all the deliveries
    201 			e.Close()
    202 			return err
    203 		}
    204 	}
    205 	return nil
    206 }
    207 
    208 // Close is called when the connection is closed
    209 // The server really should call this with error status from outside
    210 // we have no way to know if this is in response to an error or not.
    211 func (e *env) Close() error {
    212 	for _, delivery := range e.deliveries {
    213 		err := delivery.Close()
    214 		if err != nil {
    215 			return err
    216 		}
    217 	}
    218 	return nil
    219 }
    220 
    221 // onNewConnection is called when a client connects to letterbox
    222 // It checks the client IP against the allowedHosts and allowedNetwork lists,
    223 // rejecting the connection if it doesn't match.
    224 func onNewConnection(c smtpd.Connection) error {
    225 	client, _, err := net.SplitHostPort(c.Addr().String())
    226 	if err != nil {
    227 		log.Printf("Problem parsing client address %s: %s", c.Addr().String(), err)
    228 		return errors.New("Problem parsing client address")
    229 	}
    230 	clientIP := net.ParseIP(client)
    231 	logDebugf("Connection from %s\n", clientIP.String())
    232 	for _, h := range allowedHosts {
    233 		if h.Equal(clientIP) {
    234 			logDebugf("Connection from %s allowed by hosts\n", clientIP.String())
    235 			return nil
    236 		}
    237 	}
    238 
    239 	for _, n := range allowedNetworks {
    240 		if n.Contains(clientIP) {
    241 			logDebugf("Connection from %s allowed by network\n", clientIP.String())
    242 			return nil
    243 		}
    244 	}
    245 
    246 	logDebugf("Connection from %s rejected\n", clientIP.String())
    247 	return errors.New("Client IP not allowed")
    248 }
    249 
    250 // onNewMail is called when a new connection is allowed
    251 // it creates a new envelope struct which is used to hold the information about
    252 // the recipients.
    253 func onNewMail(c smtpd.Connection, from smtpd.MailAddress) (smtpd.Envelope, error) {
    254 	logDebugf("letterbox: new mail from %q", from)
    255 	return &env{}, nil
    256 }
    257 
    258 func main() {
    259 	parseArgs()
    260 
    261 	// Setup logging to a file if selected
    262 	if len(cmdline.Logfile) > 0 {
    263 		f, err := os.OpenFile(cmdline.Logfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600)
    264 		if err != nil {
    265 			log.Fatalf("Error opening logfile: %s", err)
    266 		}
    267 		defer f.Close()
    268 		log.SetOutput(f)
    269 	}
    270 
    271 	var err error
    272 	cfgFile, err := os.Open(cmdline.Config)
    273 	if err != nil {
    274 		log.Fatalf("Error opening config file: %s", err)
    275 	}
    276 	cfg, err = readConfig(cfgFile)
    277 	cfgFile.Close()
    278 	if err != nil {
    279 		log.Fatalf("Error reading config file %s: %s\n", cmdline.Config, err)
    280 	}
    281 	parseHosts()
    282 	log.Printf("letterbox: %s:%d", cmdline.Host, cmdline.Port)
    283 	log.Println("Allowed Hosts")
    284 	for _, h := range allowedHosts {
    285 		log.Printf("    %s\n", h.String())
    286 	}
    287 	log.Println("Allowed Networks")
    288 	for _, n := range allowedNetworks {
    289 		log.Printf("    %s\n", n.String())
    290 	}
    291 
    292 	s := &smtpd.Server{
    293 		Addr:            fmt.Sprintf("%s:%d", cmdline.Host, cmdline.Port),
    294 		OnNewConnection: onNewConnection,
    295 		OnNewMail:       onNewMail,
    296 	}
    297 	err = s.ListenAndServe()
    298 	if err != nil {
    299 		log.Fatalf("ListenAndServe: %v", err)
    300 	}
    301 }