├── .travis.yml ├── LICENSE.md ├── Makefile ├── README.md ├── config └── config.go ├── examples └── demo │ ├── auth.json │ ├── cert.pem │ ├── config.json │ ├── key.pem │ └── resolve.json ├── main.go ├── smtp ├── server.go ├── session.go └── session_test.go └── tools └── mhmta-admin └── main.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.2 4 | - 1.3 5 | - 1.4 6 | - tip 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ian Kent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DEPS = $(go list -f '{{range .TestImports}}{{.}} {{end}}' ./...) 2 | 3 | all: deps fmt combined 4 | 5 | combined: 6 | go install . 7 | 8 | release: release-deps 9 | gox -output="build/{{.Dir}}_{{.OS}}_{{.Arch}}" . 10 | 11 | fmt: 12 | go fmt ./... 13 | 14 | deps: 15 | go get github.com/mailhog/MailHog-Server 16 | go get github.com/mailhog/http 17 | go get github.com/ian-kent/gotcha/gotcha 18 | go get github.com/ian-kent/go-log/log 19 | go get github.com/ian-kent/envconf 20 | go get github.com/ian-kent/goose 21 | go get github.com/ian-kent/linkio 22 | go get github.com/jteeuwen/go-bindata/... 23 | go get labix.org/v2/mgo 24 | # added to fix travis issues 25 | go get code.google.com/p/go-uuid/uuid 26 | go get code.google.com/p/go.crypto/bcrypt 27 | 28 | test-deps: 29 | go get github.com/smartystreets/goconvey 30 | 31 | release-deps: 32 | go get github.com/mitchellh/gox 33 | 34 | .PNONY: all combined release fmt deps test-deps release-deps 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MailHog MTA [![GoDoc](https://godoc.org/github.com/mailhog/MailHog-MTA?status.svg)](https://godoc.org/github.com/mailhog/MailHog-MTA) [![Build Status](https://travis-ci.org/mailhog/MailHog-MTA.svg?branch=master)](https://travis-ci.org/mailhog/MailHog-MTA) 2 | ========= 3 | 4 | A experimental distributed mail transfer agent (MTA) based on MailHog. 5 | 6 | Documentation is incomplete and its barely configurable. 7 | 8 | Current features: 9 | 10 | - Multiple server support, e.g. 11 | - SMTP (25) 12 | - Submission (587) 13 | - SMTP support: 14 | - ESMTP 15 | - PIPELINING 16 | - AUTH PLAIN 17 | - STARTTLS 18 | - Server policies: 19 | - Require TLS 20 | - Require authentication 21 | - Require local delivery 22 | - Maximum recipients 23 | - Maximum connections 24 | 25 | ### Contributing 26 | 27 | Clone this repository to ```$GOPATH/src/github.com/mailhog/MailHog-MTA``` and type ```make deps```. 28 | 29 | Requires Go 1.2+ to build. 30 | 31 | Run tests using ```make test``` or ```goconvey```. 32 | 33 | If you make any changes, run ```go fmt ./...``` before submitting a pull request. 34 | 35 | ### Licence 36 | 37 | Copyright ©‎ 2014, Ian Kent (http://iankent.uk) 38 | 39 | Released under MIT license, see [LICENSE](LICENSE.md) for details. 40 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/mailhog/backends/config" 13 | ) 14 | 15 | // TODO: make TLSConfig and PolicySet 'ref'able 16 | 17 | // DefaultConfig provides a default (but relatively useless) configuration 18 | func DefaultConfig() *Config { 19 | return &Config{ 20 | Backends: map[string]config.BackendConfig{ 21 | "local_auth": config.BackendConfig{ 22 | Type: "local", 23 | Data: map[string]interface{}{ 24 | "config": "auth.json", 25 | }, 26 | }, 27 | "local_resolver": config.BackendConfig{ 28 | Type: "local", 29 | Data: map[string]interface{}{ 30 | "config": "resolve.json", 31 | }, 32 | }, 33 | "local_delivery": config.BackendConfig{ 34 | Type: "local", 35 | Data: map[string]interface{}{}, 36 | }, 37 | }, 38 | Servers: []*Server{ 39 | &Server{ 40 | BindAddr: "0.0.0.0:25", 41 | Hostname: "mailhog.example", 42 | PolicySet: DefaultSMTPPolicySet(), 43 | Backends: Backends{ 44 | Auth: &config.BackendConfig{ 45 | Ref: "local_auth", 46 | }, 47 | Resolver: &config.BackendConfig{ 48 | Ref: "local_resolver", 49 | }, 50 | Delivery: &config.BackendConfig{ 51 | Ref: "local_delivery", 52 | }, 53 | }, 54 | }, 55 | &Server{ 56 | BindAddr: "0.0.0.0:587", 57 | Hostname: "mailhog.example", 58 | PolicySet: DefaultSubmissionPolicySet(), 59 | Backends: Backends{ 60 | Auth: &config.BackendConfig{ 61 | Ref: "local_auth", 62 | }, 63 | Resolver: &config.BackendConfig{ 64 | Ref: "local_resolver", 65 | }, 66 | Delivery: &config.BackendConfig{ 67 | Ref: "local_delivery", 68 | }, 69 | }, 70 | }, 71 | }, 72 | } 73 | } 74 | 75 | // Config defines the top-level application configuration 76 | type Config struct { 77 | relPath string 78 | 79 | Servers []*Server `json:",omitempty"` 80 | Backends map[string]config.BackendConfig `json:",omitempty"` 81 | } 82 | 83 | // RelPath returns the path to the configuration file directory, 84 | // used when loading external files using relative paths 85 | func (c Config) RelPath() string { 86 | return c.relPath 87 | } 88 | 89 | // Server defines the configuration of an individual bind address 90 | type Server struct { 91 | BindAddr string `json:",omitempty"` 92 | Hostname string `json:",omitempty"` 93 | PolicySet ServerPolicySet `json:",omitempty"` 94 | Backends Backends `json:",omitempty"` 95 | TLSConfig TLSConfig `json:",omitempty"` 96 | } 97 | 98 | // TLSConfig holds a servers TLS config 99 | type TLSConfig struct { 100 | CertFile string `json:",omitempty"` 101 | KeyFile string `json:",omitempty"` 102 | } 103 | 104 | // ServerPolicySet defines the policies which can be applied per-server 105 | type ServerPolicySet struct { 106 | // RequireAuthentication forces the server to require authentication before 107 | // any other commands (except STARTTLS) are accepted. Port 587 will typically 108 | // have this set to prevent abuse. 109 | RequireAuthentication bool 110 | // RequireLocalDelivery requires messages to be addressed to local domains 111 | // (primary or secondary local). E.g., port 25 will typically have this 112 | // set to avoid becoming an open relay. 113 | RequireLocalDelivery bool 114 | // MaximumRecipients is the maximum number of recipients accepted per-message. 115 | // Additional recipients will be rejected. 116 | MaximumRecipients int 117 | // DisableTLS disables the STARTTLS command. 118 | DisableTLS bool 119 | // RequireTLS requires all connections use TLS, disabling all commands except 120 | // STARTTLS until TLS negotiation is complete. 121 | RequireTLS bool 122 | // MaximumLineLength is the maximum length of a line in the SMTP conversation. 123 | MaximumLineLength int 124 | // MaximumConnections is the maximum number of concurrent connections the 125 | // server will accept. 126 | MaximumConnections int 127 | // RejectInvalidRecipients means invalid recipients at valid primary local domains 128 | // will be rejected at the 'RCPT TO' stage. The default behaviour is to accept 129 | // the message (and bounce it later) to minimise directory harvesting. 130 | RejectInvalidRecipients bool 131 | } 132 | 133 | // Backends defines the backend configurations for a server 134 | type Backends struct { 135 | Auth *config.BackendConfig `json:",omitempty"` 136 | Resolver *config.BackendConfig `json:",omitempty"` 137 | Delivery *config.BackendConfig `json:",omitempty"` 138 | } 139 | 140 | // DefaultSubmissionPolicySet defines the default ServerPolicySet for a submission server 141 | func DefaultSubmissionPolicySet() ServerPolicySet { 142 | return ServerPolicySet{ 143 | RequireAuthentication: true, 144 | RequireLocalDelivery: false, 145 | MaximumRecipients: 500, 146 | DisableTLS: false, 147 | RequireTLS: true, 148 | MaximumLineLength: 1024000, 149 | MaximumConnections: 1000, 150 | RejectInvalidRecipients: false, 151 | } 152 | } 153 | 154 | // DefaultSMTPPolicySet defines the default ServerPolicySet for an SMTP server 155 | func DefaultSMTPPolicySet() ServerPolicySet { 156 | return ServerPolicySet{ 157 | RequireAuthentication: false, 158 | RequireLocalDelivery: true, 159 | MaximumRecipients: 500, 160 | RequireTLS: false, 161 | DisableTLS: false, 162 | MaximumLineLength: 1024000, 163 | MaximumConnections: 1000, 164 | RejectInvalidRecipients: false, 165 | } 166 | } 167 | 168 | var cfg = DefaultConfig() 169 | 170 | var configFile string 171 | 172 | // Configure returns the configuration 173 | func Configure() *Config { 174 | if len(configFile) > 0 { 175 | b, err := ioutil.ReadFile(configFile) 176 | if err != nil { 177 | fmt.Printf("Error reading %s: %s", configFile, err) 178 | os.Exit(1) 179 | } 180 | switch { 181 | case strings.HasSuffix(configFile, ".json"): 182 | err = json.Unmarshal(b, &cfg) 183 | if err != nil { 184 | fmt.Printf("Error parsing JSON in %s: %s", configFile, err) 185 | os.Exit(3) 186 | } 187 | default: 188 | fmt.Printf("Unsupported file type: %s\n", configFile) 189 | os.Exit(2) 190 | } 191 | 192 | cfg.relPath = filepath.Dir(configFile) 193 | } 194 | 195 | b, _ := json.MarshalIndent(&cfg, "", " ") 196 | fmt.Println(string(b)) 197 | 198 | return cfg 199 | } 200 | 201 | // RegisterFlags registers command line options 202 | func RegisterFlags() { 203 | flag.StringVar(&configFile, "config-file", "", "Path to configuration file") 204 | } 205 | -------------------------------------------------------------------------------- /examples/demo/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "test@mailhog.example": { 3 | "Username": "test@mailhog.example", 4 | "Password": "JDJhJDExJFEwbHpOR1V4cU1hQVNOa21iMmthSGVPaHZwT3RHNjJaQ21CZjJmQnhoQXNLbWRXSzdLbFFp", 5 | "#Password": "test", 6 | "ValidSenders": ["test@mailhog.example", "alias@mailhog.example"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/demo/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDADCCAeigAwIBAgIRALXEKVBylXbyrJYOpMW1Np8wDQYJKoZIhvcNAQELBQAw 3 | EjEQMA4GA1UEChMHQWNtZSBDbzAeFw0xNTEyMDgyMzAwMDlaFw0xNjEyMDcyMzAw 4 | MDlaMBIxEDAOBgNVBAoTB0FjbWUgQ28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw 5 | ggEKAoIBAQClVRoGE3jTpDz846fFoo+iZnD+M+J4PllZtPbOzQZDEBds9hqPV4Ny 6 | ZLVWOVdT25uFaBCOyAY3+eVFNlwgpu8XvyiPIMUcz6rGrEK6ylcNfKtYEUoxId+Q 7 | hOH4abEYyQav/bkgXGW3RK1Mc/NYgrxWEbPZRvPsOE7GoYuZc/awVjy+sAWUeV7a 8 | 6dSm7vSD4MVp+ljILLjUUNxrsSp863c4WtACgt2lpcI4y2AW0cYHwSII1op3m0ot 9 | aqW+alfW/GCSLuEPHNgqcawjG9lKBOVlVjKqbDZlit9tGzmkecJ7QKnq9u1ywEib 10 | pIH38FKSPLmIg6/He4TaDhxBn/TDXWx1AgMBAAGjUTBPMA4GA1UdDwEB/wQEAwIF 11 | oDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMBoGA1UdEQQTMBGC 12 | D21haWxob2cuZXhhbXBsZTANBgkqhkiG9w0BAQsFAAOCAQEAMsVKW6wrNS8Ibedc 13 | OydjXDUQSidQcZJ5tld8enJ88mIF83u2MmhQiwaR86EbqJym8sDvDVcXJ3uWg9bq 14 | kPS445+WKGrEHQAVifUDRc4zuj03BHo3Y/gBg7ILD7QeRjL9Zr/q/NeXE/I4bGY1 15 | JnJB08OkAExTdQIQN5sk2DDg2HU6BR/PDJjRBffF1CF0kjj3vCQX0A3PE0EzTH/y 16 | Hov+CMcx1TsjzUmaLY4J2+nayc66yMaH+ocCDwNv7+V7J3QtdOlH74vcb/WJt9DN 17 | svTKcLiG/MfGnofr1n37ih1Tej9JtrDcJF/LtUyq9GUMuzLnBLGEZWh3RqFljns2 18 | 1+O/1A== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /examples/demo/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "Servers": [{ 3 | "BindAddr": "0.0.0.0:25", 4 | "Hostname": "mailhog.example", 5 | "PolicySet": { 6 | "RequireAuthentication": false, 7 | "RequireLocalDelivery": true, 8 | "MaximumRecipients": 500, 9 | "EnableTLS": false, 10 | "RequireTLS": false, 11 | "MaximumLineLength": 1024000, 12 | "MaximumConnections": 1000, 13 | "RejectInvalidRecipients": false 14 | }, 15 | "TLSConfig": { 16 | "CertFile": "cert.pem", 17 | "KeyFile": "key.pem" 18 | }, 19 | "Backends": { 20 | "Auth": { 21 | "Ref": "local_auth" 22 | }, 23 | "Resolver": { 24 | "Ref": "local_resolver" 25 | }, 26 | "Delivery": { 27 | "Ref": "local_delivery" 28 | } 29 | } 30 | }, { 31 | "BindAddr": "0.0.0.0:587", 32 | "Hostname": "mailhog.example", 33 | "PolicySet": { 34 | "RequireAuthentication": true, 35 | "RequireLocalDelivery": false, 36 | "MaximumRecipients": 500, 37 | "EnableTLS": true, 38 | "RequireTLS": true, 39 | "MaximumLineLength": 1024000, 40 | "MaximumConnections": 1000, 41 | "RejectInvalidRecipients": false 42 | }, 43 | "TLSConfig": { 44 | "CertFile": "cert.pem", 45 | "KeyFile": "key.pem" 46 | }, 47 | "Backends": { 48 | "Auth": { 49 | "Ref": "local_auth" 50 | }, 51 | "Resolver": { 52 | "Ref": "local_resolver" 53 | }, 54 | "Delivery": { 55 | "Ref": "local_delivery" 56 | } 57 | } 58 | }], 59 | "Backends": { 60 | "local_auth": { 61 | "Type": "local", 62 | "Data": { 63 | "config": "auth.json" 64 | } 65 | }, 66 | "local_delivery": { 67 | "Type": "local", 68 | "Data": { 69 | "spool_path": "spool" 70 | } 71 | }, 72 | "local_resolver": { 73 | "Type": "local", 74 | "Data": { 75 | "config": "resolve.json" 76 | } 77 | }, 78 | "local_mailbox": { 79 | "Type": "local", 80 | "Data": { 81 | "maildir_path": "maildir" 82 | } 83 | } 84 | }, 85 | "Mailbox": { 86 | "Ref": "local_mailbox" 87 | }, 88 | "Delivery": { 89 | "Ref": "local_delivery" 90 | }, 91 | "Resolver": { 92 | "Ref": "local_resolver" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /examples/demo/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEApVUaBhN406Q8/OOnxaKPomZw/jPieD5ZWbT2zs0GQxAXbPYa 3 | j1eDcmS1VjlXU9ubhWgQjsgGN/nlRTZcIKbvF78ojyDFHM+qxqxCuspXDXyrWBFK 4 | MSHfkITh+GmxGMkGr/25IFxlt0StTHPzWIK8VhGz2Ubz7DhOxqGLmXP2sFY8vrAF 5 | lHle2unUpu70g+DFafpYyCy41FDca7EqfOt3OFrQAoLdpaXCOMtgFtHGB8EiCNaK 6 | d5tKLWqlvmpX1vxgki7hDxzYKnGsIxvZSgTlZVYyqmw2ZYrfbRs5pHnCe0Cp6vbt 7 | csBIm6SB9/BSkjy5iIOvx3uE2g4cQZ/0w11sdQIDAQABAoIBACU1MscdSLrwol0T 8 | auV6gTK+NT2wNY50Ea2zoTvHPlqHW45FEJMj0cxDx9+gxft0V9q9IcTQVT3xulxK 9 | MI+UoghJF/qmGFY0ki1mBRp+gPrjDLikI3tNMUAX97btKlL2os+mnSwgPy/wf8PN 10 | 8H0B5xrDnyMN6cVGosvm/UDKrUDfur8uiY3Ooq/ufZl6lZb1mCuFo3KkdYPwHNa9 11 | +m2EARKrUBpgSX918SNwypteIoFCT5aWGVxvPZGr7hcqACOGTs1qR+9oYzp9IbK7 12 | qa80dILKs0g4mi/MZey1I3SZlhzNmYehfZlbl7xW/7ve0u2Mx18FV2AcIFSv2bMo 13 | XcT/wx0CgYEA22oCutCzelGPTY2xpzm1dMrMKWslBjLNNNZqnA8wJZEcq0V5aJfl 14 | doMZPETmcJuHOu8qP+fHwZHLGfkMw/45NXuW+xGSI8NFzdPaOoBKpRxh5ID9mr33 15 | iRqANUpCbikICdEcvMwzCPqR9HoR8KyL2BogA2mEniRzZuxk3zgDweMCgYEAwOaK 16 | h8Tc/IgVXidY/D1tBtWvX98j/LREM26OGXNUn8QVZJnCscpl30XzttlsCfJ8kvRu 17 | GgWnPPwIhqGx8fcYSCqNdfA0uVE1V5YISFEyhgETKjvm66ES5j75OoLZMH1Flds1 18 | y//VWS4WEy2rTc7+MMYm4b/rVW8Cg7V1HDhxh8cCgYBGREDzivqvZYc7EvGd2EFg 19 | UcHoUcPdpE9LaI9jwwlsPnir8OfcsyhtN7bRMk+KKIS6PvWM2bGDMCmW+8c2zSeN 20 | FTNY3Fus0FB+hiYRLhy5m8lN4HFXKRco9S+x4UI8/S7x1eIaJFsDuRsc7CrqpJd0 21 | cYlnDlfGPW4nu/Th95JceQKBgGbWAKAkqRLPkWSiYWQHcyojnNzlXpAHohwxIfwb 22 | ac/KfwUkm5Cgr/J5nlWqT1h2N0c4m8GvpdpzGjB73xt5eS5v0P5A0jrBOki5KS00 23 | bFTYGdl4GcEgG603gTJaM2MQRZqARIu+lYR3dzk+LYbLhOOHn47V+6WOCq8ge5BR 24 | 3uRdAoGBAJMuhwhZfc7F7uuOcw/upMYTWSP8Isqcu+Y6Vq3hVXxmJXFQN1XXhwjX 25 | W2vCy6PJs+x32uNMKSNzSGhltYiPYZh6+3LDqJ8GehNi5Mb2xPeYKH0VsiUZKpfe 26 | 7H76M9svxQLKElcUrJ8J83+l/F982yRk23b4l4rrj+Id1m+GJe0X 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /examples/demo/resolve.json: -------------------------------------------------------------------------------- 1 | { 2 | "mailhog.example": { 3 | "name": "mailhog.example", 4 | "state": 1, 5 | "mailboxes": { 6 | "test": { 7 | "name": "test", 8 | "state": 2 9 | } 10 | } 11 | }, 12 | "mailhog.internal": { 13 | "name": "mailhog.internal", 14 | "state": 2 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "sync" 7 | 8 | "github.com/mailhog/MailHog-MTA/config" 9 | "github.com/mailhog/MailHog-MTA/smtp" 10 | "github.com/mailhog/backends/auth" 11 | sconfig "github.com/mailhog/backends/config" 12 | "github.com/mailhog/backends/delivery" 13 | "github.com/mailhog/backends/resolver" 14 | ) 15 | 16 | var conf *config.Config 17 | var wg sync.WaitGroup 18 | 19 | func configure() { 20 | config.RegisterFlags() 21 | flag.Parse() 22 | conf = config.Configure() 23 | } 24 | 25 | func main() { 26 | configure() 27 | 28 | for _, s := range conf.Servers { 29 | wg.Add(1) 30 | go func(s *config.Server) { 31 | defer wg.Done() 32 | err := newServer(conf, s) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | }(s) 37 | } 38 | 39 | wg.Wait() 40 | } 41 | 42 | func newServer(cfg *config.Config, server *config.Server) error { 43 | var a, d, r sconfig.BackendConfig 44 | var err error 45 | 46 | if server.Backends.Auth != nil { 47 | a, err = server.Backends.Auth.Resolve(cfg.Backends) 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | if server.Backends.Delivery != nil { 53 | d, err = server.Backends.Delivery.Resolve(cfg.Backends) 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | if server.Backends.Resolver != nil { 59 | r, err = server.Backends.Resolver.Resolve(cfg.Backends) 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | 65 | s := &smtp.Server{ 66 | BindAddr: server.BindAddr, 67 | Hostname: server.Hostname, 68 | PolicySet: server.PolicySet, 69 | AuthBackend: auth.Load(a, *cfg), 70 | DeliveryBackend: delivery.Load(d, *cfg), 71 | ResolverBackend: resolver.Load(r, *cfg), 72 | Config: cfg, 73 | Server: server, 74 | } 75 | 76 | return s.Listen() 77 | } 78 | -------------------------------------------------------------------------------- /smtp/server.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "crypto/tls" 5 | "io" 6 | "log" 7 | "net" 8 | "path/filepath" 9 | 10 | "github.com/mailhog/MailHog-MTA/config" 11 | "github.com/mailhog/backends/auth" 12 | "github.com/mailhog/backends/delivery" 13 | "github.com/mailhog/backends/resolver" 14 | ) 15 | 16 | // Server represents an SMTP server instance 17 | type Server struct { 18 | BindAddr string 19 | Hostname string 20 | PolicySet config.ServerPolicySet 21 | 22 | AuthBackend auth.Service 23 | DeliveryBackend delivery.Service 24 | ResolverBackend resolver.Service 25 | 26 | tlsConfig *tls.Config 27 | 28 | Config *config.Config 29 | Server *config.Server 30 | } 31 | 32 | func (s *Server) getTLSConfig() *tls.Config { 33 | if s.tlsConfig != nil { 34 | return s.tlsConfig 35 | } 36 | certPath := filepath.Join(s.Config.RelPath(), s.Server.TLSConfig.CertFile) 37 | keyPath := filepath.Join(s.Config.RelPath(), s.Server.TLSConfig.KeyFile) 38 | cert, err := tls.LoadX509KeyPair(certPath, keyPath) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | s.tlsConfig = &tls.Config{ 43 | Certificates: []tls.Certificate{cert}, 44 | } 45 | return s.tlsConfig 46 | } 47 | 48 | // Listen starts listening on the configured bind address 49 | func (s *Server) Listen() error { 50 | log.Printf("[SMTP] Binding to address: %s\n", s.BindAddr) 51 | ln, err := net.Listen("tcp", s.BindAddr) 52 | if err != nil { 53 | log.Fatalf("[SMTP] Error listening on socket: %s\n", err) 54 | return err 55 | } 56 | 57 | defer ln.Close() 58 | 59 | sem := make(chan int, s.PolicySet.MaximumConnections) 60 | 61 | for { 62 | sem <- 1 63 | 64 | conn, err := ln.Accept() 65 | if err != nil { 66 | log.Printf("[SMTP] Error accepting connection: %s\n", err) 67 | continue 68 | } 69 | 70 | go func() { 71 | s.Accept( 72 | conn.(*net.TCPConn).RemoteAddr().String(), 73 | io.ReadWriteCloser(conn), 74 | ) 75 | 76 | <-sem 77 | }() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /smtp/session.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // http://www.rfc-editor.org/rfc/rfc5321.txt 4 | 5 | import ( 6 | "crypto/tls" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "strings" 12 | 13 | "github.com/mailhog/backends/auth" 14 | "github.com/mailhog/backends/resolver" 15 | "github.com/mailhog/data" 16 | "github.com/mailhog/smtp" 17 | ) 18 | 19 | // Session represents a SMTP session using net.TCPConn 20 | type Session struct { 21 | server *Server 22 | 23 | conn io.ReadWriteCloser 24 | proto *smtp.Protocol 25 | remoteAddress string 26 | isTLS bool 27 | line string 28 | identity auth.Identity 29 | 30 | maximumBufferLength int 31 | } 32 | 33 | // Accept starts a new SMTP session using io.ReadWriteCloser 34 | func (s *Server) Accept(remoteAddress string, conn io.ReadWriteCloser) { 35 | proto := smtp.NewProtocol() 36 | proto.Hostname = s.Hostname 37 | 38 | session := &Session{ 39 | server: s, 40 | conn: conn, 41 | proto: proto, 42 | remoteAddress: remoteAddress, 43 | isTLS: false, 44 | line: "", 45 | identity: nil, 46 | maximumBufferLength: 2048000, 47 | } 48 | 49 | // FIXME this all feels nasty 50 | proto.LogHandler = session.logf 51 | proto.MessageReceivedHandler = session.acceptMessage 52 | proto.ValidateSenderHandler = session.validateSender 53 | proto.ValidateRecipientHandler = session.validateRecipient 54 | proto.ValidateAuthenticationHandler = session.validateAuthentication 55 | if session.server != nil && session.server.AuthBackend != nil { 56 | proto.GetAuthenticationMechanismsHandler = session.server.AuthBackend.Mechanisms 57 | } 58 | proto.SMTPVerbFilter = session.verbFilter 59 | proto.MaximumRecipients = session.server.PolicySet.MaximumRecipients 60 | proto.MaximumLineLength = session.server.PolicySet.MaximumLineLength 61 | 62 | if !session.server.PolicySet.DisableTLS { 63 | session.logf("Enabling TLS support") 64 | proto.TLSHandler = session.tlsHandler 65 | proto.RequireTLS = session.server.PolicySet.RequireTLS 66 | } 67 | 68 | session.logf("Starting session") 69 | session.Write(proto.Start()) 70 | for session.Read() == true { 71 | } 72 | io.Closer(conn).Close() 73 | session.logf("Session ended") 74 | } 75 | 76 | func (c *Session) validateAuthentication(mechanism string, args ...string) (errorReply *smtp.Reply, ok bool) { 77 | if c.server.AuthBackend == nil { 78 | return smtp.ReplyInvalidAuth(), false 79 | } 80 | i, e, ok := c.server.AuthBackend.Authenticate(mechanism, args...) 81 | if e != nil || !ok { 82 | if e != nil { 83 | c.logf("error authenticating: %s", e) 84 | } 85 | return smtp.ReplyInvalidAuth(), false 86 | } 87 | c.identity = i 88 | return nil, true 89 | } 90 | 91 | func (c *Session) validateRecipient(to string) bool { 92 | if c.server.DeliveryBackend == nil { 93 | return false 94 | } 95 | 96 | maxRecipients := c.server.PolicySet.MaximumRecipients 97 | if maxRecipients > -1 && len(c.proto.Message.To) > maxRecipients { 98 | return false 99 | } 100 | 101 | if c.identity != nil && 102 | c.identity.PolicySet().MaximumRecipients != nil && 103 | *c.identity.PolicySet().MaximumRecipients <= len(c.proto.Message.To) { 104 | return false 105 | } 106 | 107 | r := c.server.ResolverBackend.Resolve(to) 108 | 109 | if c.server.PolicySet.RequireLocalDelivery { 110 | if r.Domain == resolver.DomainNotFound { 111 | return false 112 | } 113 | } 114 | 115 | if r.Domain == resolver.DomainPrimaryLocal && 116 | r.Mailbox != resolver.MailboxFound && 117 | (c.server.PolicySet.RejectInvalidRecipients || 118 | (c.identity != nil && 119 | c.identity.PolicySet().RejectInvalidRecipients != nil && 120 | *c.identity.PolicySet().RejectInvalidRecipients)) { 121 | return false 122 | } 123 | 124 | return c.server.DeliveryBackend.WillDeliver(to, c.proto.Message.From, c.identity) 125 | } 126 | 127 | func (c *Session) validateSender(from string) bool { 128 | // we have a user (authenticated outbound SMTP) 129 | if c.identity != nil { 130 | return c.identity.IsValidSender(from) 131 | } 132 | 133 | // we don't, but we should (unauthenticated outbound SMTP) 134 | if c.server.PolicySet.RequireAuthentication { 135 | return false 136 | } 137 | 138 | // we don't, but we don't care (inbound SMTP) 139 | return true 140 | } 141 | 142 | func (c *Session) verbFilter(verb string, args ...string) (errorReply *smtp.Reply) { 143 | if c.server.PolicySet.RequireAuthentication && c.identity == nil { 144 | verb = strings.ToUpper(verb) 145 | if verb == "RSET" || verb == "QUIT" || verb == "NOOP" || 146 | verb == "EHLO" || verb == "HELO" || verb == "AUTH" || 147 | verb == "STARTTLS" { 148 | return nil 149 | } 150 | // FIXME more appropriate error 151 | c.logf("Use of verb not permitted in this state") 152 | return smtp.ReplyUnrecognisedCommand() 153 | } 154 | return nil 155 | } 156 | 157 | // tlsHandler handles the STARTTLS command 158 | func (c *Session) tlsHandler(done func(ok bool)) (errorReply *smtp.Reply, callback func(), ok bool) { 159 | c.logf("Returning TLS handler") 160 | return nil, func() { 161 | c.logf("Upgrading session to TLS") 162 | // FIXME errors reading TLS config? should preload it 163 | tConn := tls.Server(c.conn.(net.Conn), c.server.getTLSConfig()) 164 | err := tConn.Handshake() 165 | c.conn = tConn 166 | if err != nil { 167 | c.logf("handshake error in TLS connection: %s", err) 168 | done(false) 169 | return 170 | } 171 | c.isTLS = true 172 | c.logf("Session upgrade complete") 173 | done(true) 174 | }, true 175 | } 176 | 177 | func (c *Session) acceptMessage(msg *data.SMTPMessage) (id string, err error) { 178 | id, err = c.server.DeliveryBackend.Deliver(msg) 179 | c.logf("Storing message %s", id) 180 | return 181 | } 182 | 183 | func (c *Session) logf(message string, args ...interface{}) { 184 | message = strings.Join([]string{"[SMTP %s]", message}, " ") 185 | args = append([]interface{}{c.remoteAddress}, args...) 186 | log.Printf(message, args...) 187 | } 188 | 189 | // Read reads from the underlying io.Reader 190 | func (c *Session) Read() bool { 191 | buf := make([]byte, 1024) 192 | n, err := io.Reader(c.conn).Read(buf) 193 | 194 | if n == 0 { 195 | c.logf("Connection closed by remote host\n") 196 | io.Closer(c.conn).Close() // not sure this is necessary? 197 | return false 198 | } 199 | 200 | if err != nil { 201 | c.logf("Error reading from socket: %s\n", err) 202 | return false 203 | } 204 | 205 | text := string(buf[0:n]) 206 | logText := strings.Replace(text, "\n", "\\n", -1) 207 | logText = strings.Replace(logText, "\r", "\\r", -1) 208 | c.logf("Received %d bytes: '%s'\n", n, logText) 209 | 210 | if c.maximumBufferLength > -1 && len(c.line+text) > c.maximumBufferLength { 211 | // FIXME what is the "expected" behaviour for this? 212 | c.Write(smtp.ReplyError(fmt.Errorf("Maximum buffer length exceeded"))) 213 | return false 214 | } 215 | 216 | c.line += text 217 | 218 | for strings.Contains(c.line, "\r\n") { 219 | line, reply := c.proto.Parse(c.line) 220 | c.line = line 221 | 222 | if reply != nil { 223 | c.Write(reply) 224 | if reply.Status == 221 { 225 | return false 226 | } 227 | } 228 | } 229 | 230 | return true 231 | } 232 | 233 | // Write writes a reply to the underlying io.Writer 234 | func (c *Session) Write(reply *smtp.Reply) { 235 | lines := reply.Lines() 236 | for _, l := range lines { 237 | logText := strings.Replace(l, "\n", "\\n", -1) 238 | logText = strings.Replace(logText, "\r", "\\r", -1) 239 | c.logf("Sent %d bytes: '%s'", len(l), logText) 240 | io.Writer(c.conn).Write([]byte(l)) 241 | } 242 | if reply.Done != nil { 243 | reply.Done() 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /smtp/session_test.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | type fakeRw struct { 11 | _read func(p []byte) (n int, err error) 12 | _write func(p []byte) (n int, err error) 13 | _close func() error 14 | } 15 | 16 | func (rw *fakeRw) Read(p []byte) (n int, err error) { 17 | if rw._read != nil { 18 | return rw._read(p) 19 | } 20 | return 0, nil 21 | } 22 | func (rw *fakeRw) Close() error { 23 | if rw._close != nil { 24 | return rw._close() 25 | } 26 | return nil 27 | } 28 | func (rw *fakeRw) Write(p []byte) (n int, err error) { 29 | if rw._write != nil { 30 | return rw._write(p) 31 | } 32 | return len(p), nil 33 | } 34 | 35 | func TestAccept(t *testing.T) { 36 | Convey("Accept should handle a connection", t, func() { 37 | frw := &fakeRw{} 38 | s := &Server{} 39 | s.Accept("1.1.1.1:11111", frw) 40 | }) 41 | } 42 | 43 | func TestSocketError(t *testing.T) { 44 | Convey("Socket errors should return from Accept", t, func() { 45 | frw := &fakeRw{ 46 | _read: func(p []byte) (n int, err error) { 47 | return -1, errors.New("OINK") 48 | }, 49 | } 50 | s := &Server{} 51 | s.Accept("1.1.1.1:11111", frw) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /tools/mhmta-admin/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | const usage = `Usage: mhmta-admin [command] args... 9 | 10 | Commands: 11 | add-user add a user to the authentication registry 12 | ` 13 | 14 | func main() { 15 | if len(os.Args) >= 2 { 16 | cmd := os.Args[1] 17 | switch cmd { 18 | case "add-user": 19 | default: 20 | fmt.Printf("Unrecognised command '%s'\n", cmd) 21 | os.Exit(1) 22 | } 23 | } 24 | fmt.Printf("%s", usage) 25 | } 26 | --------------------------------------------------------------------------------