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 }