├── .travis.yml ├── LICENSE.md ├── Makefile ├── README.md ├── api ├── api.go ├── v1.go └── v2.go ├── config └── config.go ├── main.go ├── monkey ├── jim.go └── monkey.go ├── smtp ├── session.go ├── session_test.go └── smtp.go └── websockets ├── connection.go └── hub.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.6 4 | - tip 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 - 2016 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: release-deps fmt combined 4 | 5 | combined: 6 | go install . 7 | 8 | release: 9 | gox -output="build/{{.Dir}}_{{.OS}}_{{.Arch}}" . 10 | 11 | fmt: 12 | go fmt ./... 13 | 14 | release-deps: 15 | go get github.com/mitchellh/gox 16 | 17 | .PNONY: all combined release fmt release-deps 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MailHog Server [![Build Status](https://travis-ci.org/mailhog/MailHog-Server.svg?branch=master)](https://travis-ci.org/mailhog/MailHog-Server) 2 | ========= 3 | 4 | MailHog-Server is the MailHog SMTP and HTTP API server. 5 | 6 | ### Licence 7 | 8 | Copyright ©‎ 2014 - 2016, Ian Kent (http://iankent.uk) 9 | 10 | Released under MIT license, see [LICENSE](LICENSE.md) for details. 11 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | gohttp "net/http" 5 | 6 | "github.com/gorilla/pat" 7 | "github.com/mailhog/MailHog-Server/config" 8 | ) 9 | 10 | func CreateAPI(conf *config.Config, r gohttp.Handler) { 11 | apiv1 := createAPIv1(conf, r.(*pat.Router)) 12 | apiv2 := createAPIv2(conf, r.(*pat.Router)) 13 | 14 | go func() { 15 | for { 16 | select { 17 | case msg := <-conf.MessageChan: 18 | apiv1.messageChan <- msg 19 | apiv2.messageChan <- msg 20 | } 21 | } 22 | }() 23 | } 24 | -------------------------------------------------------------------------------- /api/v1.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "net/http" 7 | "net/smtp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gorilla/pat" 13 | "github.com/ian-kent/go-log/log" 14 | "github.com/mailhog/MailHog-Server/config" 15 | "github.com/mailhog/data" 16 | "github.com/mailhog/storage" 17 | 18 | "github.com/ian-kent/goose" 19 | ) 20 | 21 | // APIv1 implements version 1 of the MailHog API 22 | // 23 | // The specification has been frozen and will eventually be deprecated. 24 | // Only bug fixes and non-breaking changes will be applied here. 25 | // 26 | // Any changes/additions should be added in APIv2. 27 | type APIv1 struct { 28 | config *config.Config 29 | messageChan chan *data.Message 30 | } 31 | 32 | // FIXME should probably move this into APIv1 struct 33 | var stream *goose.EventStream 34 | 35 | // ReleaseConfig is an alias to preserve go package API 36 | type ReleaseConfig config.OutgoingSMTP 37 | 38 | func createAPIv1(conf *config.Config, r *pat.Router) *APIv1 { 39 | log.Println("Creating API v1 with WebPath: " + conf.WebPath) 40 | apiv1 := &APIv1{ 41 | config: conf, 42 | messageChan: make(chan *data.Message), 43 | } 44 | 45 | stream = goose.NewEventStream() 46 | 47 | r.Path(conf.WebPath + "/api/v1/messages").Methods("GET").HandlerFunc(apiv1.messages) 48 | r.Path(conf.WebPath + "/api/v1/messages").Methods("DELETE").HandlerFunc(apiv1.delete_all) 49 | r.Path(conf.WebPath + "/api/v1/messages").Methods("OPTIONS").HandlerFunc(apiv1.defaultOptions) 50 | 51 | r.Path(conf.WebPath + "/api/v1/messages/{id}").Methods("GET").HandlerFunc(apiv1.message) 52 | r.Path(conf.WebPath + "/api/v1/messages/{id}").Methods("DELETE").HandlerFunc(apiv1.delete_one) 53 | r.Path(conf.WebPath + "/api/v1/messages/{id}").Methods("OPTIONS").HandlerFunc(apiv1.defaultOptions) 54 | 55 | r.Path(conf.WebPath + "/api/v1/messages/{id}/download").Methods("GET").HandlerFunc(apiv1.download) 56 | r.Path(conf.WebPath + "/api/v1/messages/{id}/download").Methods("OPTIONS").HandlerFunc(apiv1.defaultOptions) 57 | 58 | r.Path(conf.WebPath + "/api/v1/messages/{id}/mime/part/{part}/download").Methods("GET").HandlerFunc(apiv1.download_part) 59 | r.Path(conf.WebPath + "/api/v1/messages/{id}/mime/part/{part}/download").Methods("OPTIONS").HandlerFunc(apiv1.defaultOptions) 60 | 61 | r.Path(conf.WebPath + "/api/v1/messages/{id}/release").Methods("POST").HandlerFunc(apiv1.release_one) 62 | r.Path(conf.WebPath + "/api/v1/messages/{id}/release").Methods("OPTIONS").HandlerFunc(apiv1.defaultOptions) 63 | 64 | r.Path(conf.WebPath + "/api/v1/events").Methods("GET").HandlerFunc(apiv1.eventstream) 65 | r.Path(conf.WebPath + "/api/v1/events").Methods("OPTIONS").HandlerFunc(apiv1.defaultOptions) 66 | 67 | go func() { 68 | keepaliveTicker := time.Tick(time.Minute) 69 | for { 70 | select { 71 | case msg := <-apiv1.messageChan: 72 | log.Println("Got message in APIv1 event stream") 73 | bytes, _ := json.MarshalIndent(msg, "", " ") 74 | json := string(bytes) 75 | log.Printf("Sending content: %s\n", json) 76 | apiv1.broadcast(json) 77 | case <-keepaliveTicker: 78 | apiv1.keepalive() 79 | } 80 | } 81 | }() 82 | 83 | return apiv1 84 | } 85 | 86 | func (apiv1 *APIv1) defaultOptions(w http.ResponseWriter, req *http.Request) { 87 | if len(apiv1.config.CORSOrigin) > 0 { 88 | w.Header().Add("Access-Control-Allow-Origin", apiv1.config.CORSOrigin) 89 | w.Header().Add("Access-Control-Allow-Methods", "OPTIONS,GET,POST,DELETE") 90 | w.Header().Add("Access-Control-Allow-Headers", "Content-Type") 91 | } 92 | } 93 | 94 | func (apiv1 *APIv1) broadcast(json string) { 95 | log.Println("[APIv1] BROADCAST /api/v1/events") 96 | b := []byte(json) 97 | stream.Notify("data", b) 98 | } 99 | 100 | // keepalive sends an empty keep alive message. 101 | // 102 | // This not only can keep connections alive, but also will detect broken 103 | // connections. Without this it is possible for the server to become 104 | // unresponsive due to too many open files. 105 | func (apiv1 *APIv1) keepalive() { 106 | log.Println("[APIv1] KEEPALIVE /api/v1/events") 107 | stream.Notify("keepalive", []byte{}) 108 | } 109 | 110 | func (apiv1 *APIv1) eventstream(w http.ResponseWriter, req *http.Request) { 111 | log.Println("[APIv1] GET /api/v1/events") 112 | 113 | //apiv1.defaultOptions(session) 114 | if len(apiv1.config.CORSOrigin) > 0 { 115 | w.Header().Add("Access-Control-Allow-Origin", apiv1.config.CORSOrigin) 116 | w.Header().Add("Access-Control-Allow-Methods", "OPTIONS,GET,POST,DELETE") 117 | } 118 | 119 | stream.AddReceiver(w) 120 | } 121 | 122 | func (apiv1 *APIv1) messages(w http.ResponseWriter, req *http.Request) { 123 | log.Println("[APIv1] GET /api/v1/messages") 124 | 125 | apiv1.defaultOptions(w, req) 126 | 127 | // TODO start, limit 128 | switch apiv1.config.Storage.(type) { 129 | case *storage.MongoDB: 130 | messages, _ := apiv1.config.Storage.(*storage.MongoDB).List(0, 1000) 131 | bytes, _ := json.Marshal(messages) 132 | w.Header().Add("Content-Type", "text/json") 133 | w.Write(bytes) 134 | case *storage.InMemory: 135 | messages, _ := apiv1.config.Storage.(*storage.InMemory).List(0, 1000) 136 | bytes, _ := json.Marshal(messages) 137 | w.Header().Add("Content-Type", "text/json") 138 | w.Write(bytes) 139 | default: 140 | w.WriteHeader(500) 141 | } 142 | } 143 | 144 | func (apiv1 *APIv1) message(w http.ResponseWriter, req *http.Request) { 145 | id := req.URL.Query().Get(":id") 146 | log.Printf("[APIv1] GET /api/v1/messages/%s\n", id) 147 | 148 | apiv1.defaultOptions(w, req) 149 | 150 | message, err := apiv1.config.Storage.Load(id) 151 | if err != nil { 152 | log.Printf("- Error: %s", err) 153 | w.WriteHeader(500) 154 | return 155 | } 156 | 157 | bytes, err := json.Marshal(message) 158 | if err != nil { 159 | log.Printf("- Error: %s", err) 160 | w.WriteHeader(500) 161 | return 162 | } 163 | 164 | w.Header().Set("Content-Type", "text/json") 165 | w.Write(bytes) 166 | } 167 | 168 | func (apiv1 *APIv1) download(w http.ResponseWriter, req *http.Request) { 169 | id := req.URL.Query().Get(":id") 170 | log.Printf("[APIv1] GET /api/v1/messages/%s\n", id) 171 | 172 | apiv1.defaultOptions(w, req) 173 | 174 | w.Header().Set("Content-Type", "message/rfc822") 175 | w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") 176 | 177 | switch apiv1.config.Storage.(type) { 178 | case *storage.MongoDB: 179 | message, _ := apiv1.config.Storage.(*storage.MongoDB).Load(id) 180 | for h, l := range message.Content.Headers { 181 | for _, v := range l { 182 | w.Write([]byte(h + ": " + v + "\r\n")) 183 | } 184 | } 185 | w.Write([]byte("\r\n" + message.Content.Body)) 186 | case *storage.InMemory: 187 | message, _ := apiv1.config.Storage.(*storage.InMemory).Load(id) 188 | for h, l := range message.Content.Headers { 189 | for _, v := range l { 190 | w.Write([]byte(h + ": " + v + "\r\n")) 191 | } 192 | } 193 | w.Write([]byte("\r\n" + message.Content.Body)) 194 | default: 195 | w.WriteHeader(500) 196 | } 197 | } 198 | 199 | func (apiv1 *APIv1) download_part(w http.ResponseWriter, req *http.Request) { 200 | id := req.URL.Query().Get(":id") 201 | part := req.URL.Query().Get(":part") 202 | log.Printf("[APIv1] GET /api/v1/messages/%s/mime/part/%s/download\n", id, part) 203 | 204 | // TODO extension from content-type? 205 | apiv1.defaultOptions(w, req) 206 | 207 | w.Header().Set("Content-Disposition", "attachment; filename=\""+id+"-part-"+part+"\"") 208 | 209 | message, _ := apiv1.config.Storage.Load(id) 210 | contentTransferEncoding := "" 211 | pid, _ := strconv.Atoi(part) 212 | for h, l := range message.MIME.Parts[pid].Headers { 213 | for _, v := range l { 214 | switch strings.ToLower(h) { 215 | case "content-disposition": 216 | // Prevent duplicate "content-disposition" 217 | w.Header().Set(h, v) 218 | case "content-transfer-encoding": 219 | if contentTransferEncoding == "" { 220 | contentTransferEncoding = v 221 | } 222 | fallthrough 223 | default: 224 | w.Header().Add(h, v) 225 | } 226 | } 227 | } 228 | body := []byte(message.MIME.Parts[pid].Body) 229 | if strings.ToLower(contentTransferEncoding) == "base64" { 230 | var e error 231 | body, e = base64.StdEncoding.DecodeString(message.MIME.Parts[pid].Body) 232 | if e != nil { 233 | log.Printf("[APIv1] Decoding base64 encoded body failed: %s", e) 234 | } 235 | } 236 | w.Write(body) 237 | } 238 | 239 | func (apiv1 *APIv1) delete_all(w http.ResponseWriter, req *http.Request) { 240 | log.Println("[APIv1] POST /api/v1/messages") 241 | 242 | apiv1.defaultOptions(w, req) 243 | 244 | w.Header().Add("Content-Type", "text/json") 245 | 246 | err := apiv1.config.Storage.DeleteAll() 247 | if err != nil { 248 | log.Println(err) 249 | w.WriteHeader(500) 250 | return 251 | } 252 | 253 | w.WriteHeader(200) 254 | } 255 | 256 | func (apiv1 *APIv1) release_one(w http.ResponseWriter, req *http.Request) { 257 | id := req.URL.Query().Get(":id") 258 | log.Printf("[APIv1] POST /api/v1/messages/%s/release\n", id) 259 | 260 | apiv1.defaultOptions(w, req) 261 | 262 | w.Header().Add("Content-Type", "text/json") 263 | msg, _ := apiv1.config.Storage.Load(id) 264 | 265 | decoder := json.NewDecoder(req.Body) 266 | var cfg ReleaseConfig 267 | err := decoder.Decode(&cfg) 268 | if err != nil { 269 | log.Printf("Error decoding request body: %s", err) 270 | w.WriteHeader(500) 271 | w.Write([]byte("Error decoding request body")) 272 | return 273 | } 274 | 275 | log.Printf("%+v", cfg) 276 | 277 | log.Printf("Got message: %s", msg.ID) 278 | 279 | if cfg.Save { 280 | if _, ok := apiv1.config.OutgoingSMTP[cfg.Name]; ok { 281 | log.Printf("Server already exists named %s", cfg.Name) 282 | w.WriteHeader(400) 283 | return 284 | } 285 | cf := config.OutgoingSMTP(cfg) 286 | apiv1.config.OutgoingSMTP[cfg.Name] = &cf 287 | log.Printf("Saved server with name %s", cfg.Name) 288 | } 289 | 290 | if len(cfg.Name) > 0 { 291 | if c, ok := apiv1.config.OutgoingSMTP[cfg.Name]; ok { 292 | log.Printf("Using server with name: %s", cfg.Name) 293 | cfg.Name = c.Name 294 | if len(cfg.Email) == 0 { 295 | cfg.Email = c.Email 296 | } 297 | cfg.Host = c.Host 298 | cfg.Port = c.Port 299 | cfg.Username = c.Username 300 | cfg.Password = c.Password 301 | cfg.Mechanism = c.Mechanism 302 | } else { 303 | log.Printf("Server not found: %s", cfg.Name) 304 | w.WriteHeader(400) 305 | return 306 | } 307 | } 308 | 309 | log.Printf("Releasing to %s (via %s:%s)", cfg.Email, cfg.Host, cfg.Port) 310 | 311 | bytes := make([]byte, 0) 312 | for h, l := range msg.Content.Headers { 313 | for _, v := range l { 314 | bytes = append(bytes, []byte(h+": "+v+"\r\n")...) 315 | } 316 | } 317 | bytes = append(bytes, []byte("\r\n"+msg.Content.Body)...) 318 | 319 | var auth smtp.Auth 320 | 321 | if len(cfg.Username) > 0 || len(cfg.Password) > 0 { 322 | log.Printf("Found username/password, using auth mechanism: [%s]", cfg.Mechanism) 323 | switch cfg.Mechanism { 324 | case "CRAMMD5": 325 | auth = smtp.CRAMMD5Auth(cfg.Username, cfg.Password) 326 | case "PLAIN": 327 | auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) 328 | default: 329 | log.Printf("Error - invalid authentication mechanism") 330 | w.WriteHeader(400) 331 | return 332 | } 333 | } 334 | 335 | err = smtp.SendMail(cfg.Host+":"+cfg.Port, auth, "nobody@"+apiv1.config.Hostname, []string{cfg.Email}, bytes) 336 | if err != nil { 337 | log.Printf("Failed to release message: %s", err) 338 | w.WriteHeader(500) 339 | return 340 | } 341 | log.Printf("Message released successfully") 342 | } 343 | 344 | func (apiv1 *APIv1) delete_one(w http.ResponseWriter, req *http.Request) { 345 | id := req.URL.Query().Get(":id") 346 | 347 | log.Printf("[APIv1] POST /api/v1/messages/%s/delete\n", id) 348 | 349 | apiv1.defaultOptions(w, req) 350 | 351 | w.Header().Add("Content-Type", "text/json") 352 | err := apiv1.config.Storage.DeleteOne(id) 353 | if err != nil { 354 | log.Println(err) 355 | w.WriteHeader(500) 356 | return 357 | } 358 | w.WriteHeader(200) 359 | } 360 | -------------------------------------------------------------------------------- /api/v2.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/pat" 9 | "github.com/ian-kent/go-log/log" 10 | "github.com/mailhog/MailHog-Server/config" 11 | "github.com/mailhog/MailHog-Server/monkey" 12 | "github.com/mailhog/MailHog-Server/websockets" 13 | "github.com/mailhog/data" 14 | ) 15 | 16 | // APIv2 implements version 2 of the MailHog API 17 | // 18 | // It is currently experimental and may change in future releases. 19 | // Use APIv1 for guaranteed compatibility. 20 | type APIv2 struct { 21 | config *config.Config 22 | messageChan chan *data.Message 23 | wsHub *websockets.Hub 24 | } 25 | 26 | func createAPIv2(conf *config.Config, r *pat.Router) *APIv2 { 27 | log.Println("Creating API v2 with WebPath: " + conf.WebPath) 28 | apiv2 := &APIv2{ 29 | config: conf, 30 | messageChan: make(chan *data.Message), 31 | wsHub: websockets.NewHub(), 32 | } 33 | 34 | r.Path(conf.WebPath + "/api/v2/messages").Methods("GET").HandlerFunc(apiv2.messages) 35 | r.Path(conf.WebPath + "/api/v2/messages").Methods("OPTIONS").HandlerFunc(apiv2.defaultOptions) 36 | 37 | r.Path(conf.WebPath + "/api/v2/search").Methods("GET").HandlerFunc(apiv2.search) 38 | r.Path(conf.WebPath + "/api/v2/search").Methods("OPTIONS").HandlerFunc(apiv2.defaultOptions) 39 | 40 | r.Path(conf.WebPath + "/api/v2/jim").Methods("GET").HandlerFunc(apiv2.jim) 41 | r.Path(conf.WebPath + "/api/v2/jim").Methods("POST").HandlerFunc(apiv2.createJim) 42 | r.Path(conf.WebPath + "/api/v2/jim").Methods("PUT").HandlerFunc(apiv2.updateJim) 43 | r.Path(conf.WebPath + "/api/v2/jim").Methods("DELETE").HandlerFunc(apiv2.deleteJim) 44 | r.Path(conf.WebPath + "/api/v2/jim").Methods("OPTIONS").HandlerFunc(apiv2.defaultOptions) 45 | 46 | r.Path(conf.WebPath + "/api/v2/outgoing-smtp").Methods("GET").HandlerFunc(apiv2.listOutgoingSMTP) 47 | r.Path(conf.WebPath + "/api/v2/outgoing-smtp").Methods("OPTIONS").HandlerFunc(apiv2.defaultOptions) 48 | 49 | r.Path(conf.WebPath + "/api/v2/websocket").Methods("GET").HandlerFunc(apiv2.websocket) 50 | 51 | go func() { 52 | for { 53 | select { 54 | case msg := <-apiv2.messageChan: 55 | log.Println("Got message in APIv2 websocket channel") 56 | apiv2.broadcast(msg) 57 | } 58 | } 59 | }() 60 | 61 | return apiv2 62 | } 63 | 64 | func (apiv2 *APIv2) defaultOptions(w http.ResponseWriter, req *http.Request) { 65 | if len(apiv2.config.CORSOrigin) > 0 { 66 | w.Header().Add("Access-Control-Allow-Origin", apiv2.config.CORSOrigin) 67 | w.Header().Add("Access-Control-Allow-Methods", "OPTIONS,GET,PUT,POST,DELETE") 68 | w.Header().Add("Access-Control-Allow-Headers", "Content-Type") 69 | } 70 | } 71 | 72 | type messagesResult struct { 73 | Total int `json:"total"` 74 | Count int `json:"count"` 75 | Start int `json:"start"` 76 | Items []data.Message `json:"items"` 77 | } 78 | 79 | func (apiv2 *APIv2) getStartLimit(w http.ResponseWriter, req *http.Request) (start, limit int) { 80 | start = 0 81 | limit = 50 82 | 83 | s := req.URL.Query().Get("start") 84 | if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 { 85 | start = int(n) 86 | } 87 | 88 | l := req.URL.Query().Get("limit") 89 | if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 { 90 | if n > 250 { 91 | n = 250 92 | } 93 | limit = int(n) 94 | } 95 | 96 | return 97 | } 98 | 99 | func (apiv2 *APIv2) messages(w http.ResponseWriter, req *http.Request) { 100 | log.Println("[APIv2] GET /api/v2/messages") 101 | 102 | apiv2.defaultOptions(w, req) 103 | 104 | start, limit := apiv2.getStartLimit(w, req) 105 | 106 | var res messagesResult 107 | 108 | messages, err := apiv2.config.Storage.List(start, limit) 109 | if err != nil { 110 | panic(err) 111 | } 112 | 113 | res.Count = len([]data.Message(*messages)) 114 | res.Start = start 115 | res.Items = []data.Message(*messages) 116 | res.Total = apiv2.config.Storage.Count() 117 | 118 | bytes, _ := json.Marshal(res) 119 | w.Header().Add("Content-Type", "text/json") 120 | w.Write(bytes) 121 | } 122 | 123 | func (apiv2 *APIv2) search(w http.ResponseWriter, req *http.Request) { 124 | log.Println("[APIv2] GET /api/v2/search") 125 | 126 | apiv2.defaultOptions(w, req) 127 | 128 | start, limit := apiv2.getStartLimit(w, req) 129 | 130 | kind := req.URL.Query().Get("kind") 131 | if kind != "from" && kind != "to" && kind != "containing" { 132 | w.WriteHeader(400) 133 | return 134 | } 135 | 136 | query := req.URL.Query().Get("query") 137 | if len(query) == 0 { 138 | w.WriteHeader(400) 139 | return 140 | } 141 | 142 | var res messagesResult 143 | 144 | messages, total, _ := apiv2.config.Storage.Search(kind, query, start, limit) 145 | 146 | res.Count = len([]data.Message(*messages)) 147 | res.Start = start 148 | res.Items = []data.Message(*messages) 149 | res.Total = total 150 | 151 | b, _ := json.Marshal(res) 152 | w.Header().Add("Content-Type", "application/json") 153 | w.Write(b) 154 | } 155 | 156 | func (apiv2 *APIv2) jim(w http.ResponseWriter, req *http.Request) { 157 | log.Println("[APIv2] GET /api/v2/jim") 158 | 159 | apiv2.defaultOptions(w, req) 160 | 161 | if apiv2.config.Monkey == nil { 162 | w.WriteHeader(404) 163 | return 164 | } 165 | 166 | b, _ := json.Marshal(apiv2.config.Monkey) 167 | w.Header().Add("Content-Type", "application/json") 168 | w.Write(b) 169 | } 170 | 171 | func (apiv2 *APIv2) deleteJim(w http.ResponseWriter, req *http.Request) { 172 | log.Println("[APIv2] DELETE /api/v2/jim") 173 | 174 | apiv2.defaultOptions(w, req) 175 | 176 | if apiv2.config.Monkey == nil { 177 | w.WriteHeader(404) 178 | return 179 | } 180 | 181 | apiv2.config.Monkey = nil 182 | } 183 | 184 | func (apiv2 *APIv2) createJim(w http.ResponseWriter, req *http.Request) { 185 | log.Println("[APIv2] POST /api/v2/jim") 186 | 187 | apiv2.defaultOptions(w, req) 188 | 189 | if apiv2.config.Monkey != nil { 190 | w.WriteHeader(400) 191 | return 192 | } 193 | 194 | apiv2.config.Monkey = config.Jim 195 | 196 | // Try, but ignore errors 197 | // Could be better (e.g., ok if no json, error if badly formed json) 198 | // but this works for now 199 | apiv2.newJimFromBody(w, req) 200 | 201 | w.WriteHeader(201) 202 | } 203 | 204 | func (apiv2 *APIv2) newJimFromBody(w http.ResponseWriter, req *http.Request) error { 205 | var jim monkey.Jim 206 | 207 | dec := json.NewDecoder(req.Body) 208 | err := dec.Decode(&jim) 209 | 210 | if err != nil { 211 | return err 212 | } 213 | 214 | jim.ConfigureFrom(config.Jim) 215 | 216 | config.Jim = &jim 217 | apiv2.config.Monkey = &jim 218 | 219 | return nil 220 | } 221 | 222 | func (apiv2 *APIv2) updateJim(w http.ResponseWriter, req *http.Request) { 223 | log.Println("[APIv2] PUT /api/v2/jim") 224 | 225 | apiv2.defaultOptions(w, req) 226 | 227 | if apiv2.config.Monkey == nil { 228 | w.WriteHeader(404) 229 | return 230 | } 231 | 232 | err := apiv2.newJimFromBody(w, req) 233 | if err != nil { 234 | w.WriteHeader(400) 235 | } 236 | } 237 | 238 | func (apiv2 *APIv2) listOutgoingSMTP(w http.ResponseWriter, req *http.Request) { 239 | log.Println("[APIv2] GET /api/v2/outgoing-smtp") 240 | 241 | apiv2.defaultOptions(w, req) 242 | 243 | b, _ := json.Marshal(apiv2.config.OutgoingSMTP) 244 | w.Header().Add("Content-Type", "application/json") 245 | w.Write(b) 246 | } 247 | 248 | func (apiv2 *APIv2) websocket(w http.ResponseWriter, req *http.Request) { 249 | log.Println("[APIv2] GET /api/v2/websocket") 250 | 251 | apiv2.wsHub.Serve(w, req) 252 | } 253 | 254 | func (apiv2 *APIv2) broadcast(msg *data.Message) { 255 | log.Println("[APIv2] BROADCAST /api/v2/websocket") 256 | 257 | apiv2.wsHub.Broadcast(msg) 258 | } 259 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "io/ioutil" 7 | "log" 8 | 9 | "github.com/ian-kent/envconf" 10 | "github.com/mailhog/MailHog-Server/monkey" 11 | "github.com/mailhog/data" 12 | "github.com/mailhog/storage" 13 | ) 14 | 15 | // DefaultConfig is the default config 16 | func DefaultConfig() *Config { 17 | return &Config{ 18 | SMTPBindAddr: "0.0.0.0:1025", 19 | APIBindAddr: "0.0.0.0:8025", 20 | Hostname: "mailhog.example", 21 | MongoURI: "127.0.0.1:27017", 22 | MongoDb: "mailhog", 23 | MongoColl: "messages", 24 | MaildirPath: "", 25 | StorageType: "memory", 26 | CORSOrigin: "", 27 | WebPath: "", 28 | MessageChan: make(chan *data.Message), 29 | OutgoingSMTP: make(map[string]*OutgoingSMTP), 30 | } 31 | } 32 | 33 | // Config is the config, kind of 34 | type Config struct { 35 | SMTPBindAddr string 36 | APIBindAddr string 37 | Hostname string 38 | MongoURI string 39 | MongoDb string 40 | MongoColl string 41 | StorageType string 42 | CORSOrigin string 43 | MaildirPath string 44 | InviteJim bool 45 | Storage storage.Storage 46 | MessageChan chan *data.Message 47 | Assets func(asset string) ([]byte, error) 48 | Monkey monkey.ChaosMonkey 49 | OutgoingSMTPFile string 50 | OutgoingSMTP map[string]*OutgoingSMTP 51 | WebPath string 52 | } 53 | 54 | // OutgoingSMTP is an outgoing SMTP server config 55 | type OutgoingSMTP struct { 56 | Name string 57 | Save bool 58 | Email string 59 | Host string 60 | Port string 61 | Username string 62 | Password string 63 | Mechanism string 64 | } 65 | 66 | var cfg = DefaultConfig() 67 | 68 | // Jim is a monkey 69 | var Jim = &monkey.Jim{} 70 | 71 | // Configure configures stuff 72 | func Configure() *Config { 73 | switch cfg.StorageType { 74 | case "memory": 75 | log.Println("Using in-memory storage") 76 | cfg.Storage = storage.CreateInMemory() 77 | case "mongodb": 78 | log.Println("Using MongoDB message storage") 79 | s := storage.CreateMongoDB(cfg.MongoURI, cfg.MongoDb, cfg.MongoColl) 80 | if s == nil { 81 | log.Println("MongoDB storage unavailable, reverting to in-memory storage") 82 | cfg.Storage = storage.CreateInMemory() 83 | } else { 84 | log.Println("Connected to MongoDB") 85 | cfg.Storage = s 86 | } 87 | case "maildir": 88 | log.Println("Using maildir message storage") 89 | s := storage.CreateMaildir(cfg.MaildirPath) 90 | cfg.Storage = s 91 | default: 92 | log.Fatalf("Invalid storage type %s", cfg.StorageType) 93 | } 94 | 95 | Jim.Configure(func(message string, args ...interface{}) { 96 | log.Printf(message, args...) 97 | }) 98 | if cfg.InviteJim { 99 | cfg.Monkey = Jim 100 | } 101 | 102 | if len(cfg.OutgoingSMTPFile) > 0 { 103 | b, err := ioutil.ReadFile(cfg.OutgoingSMTPFile) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | var o map[string]*OutgoingSMTP 108 | err = json.Unmarshal(b, &o) 109 | if err != nil { 110 | log.Fatal(err) 111 | } 112 | cfg.OutgoingSMTP = o 113 | } 114 | 115 | return cfg 116 | } 117 | 118 | // RegisterFlags registers flags 119 | func RegisterFlags() { 120 | flag.StringVar(&cfg.SMTPBindAddr, "smtp-bind-addr", envconf.FromEnvP("MH_SMTP_BIND_ADDR", "0.0.0.0:1025").(string), "SMTP bind interface and port, e.g. 0.0.0.0:1025 or just :1025") 121 | flag.StringVar(&cfg.APIBindAddr, "api-bind-addr", envconf.FromEnvP("MH_API_BIND_ADDR", "0.0.0.0:8025").(string), "HTTP bind interface and port for API, e.g. 0.0.0.0:8025 or just :8025") 122 | flag.StringVar(&cfg.Hostname, "hostname", envconf.FromEnvP("MH_HOSTNAME", "mailhog.example").(string), "Hostname for EHLO/HELO response, e.g. mailhog.example") 123 | flag.StringVar(&cfg.StorageType, "storage", envconf.FromEnvP("MH_STORAGE", "memory").(string), "Message storage: 'memory' (default), 'mongodb' or 'maildir'") 124 | flag.StringVar(&cfg.MongoURI, "mongo-uri", envconf.FromEnvP("MH_MONGO_URI", "127.0.0.1:27017").(string), "MongoDB URI, e.g. 127.0.0.1:27017") 125 | flag.StringVar(&cfg.MongoDb, "mongo-db", envconf.FromEnvP("MH_MONGO_DB", "mailhog").(string), "MongoDB database, e.g. mailhog") 126 | flag.StringVar(&cfg.MongoColl, "mongo-coll", envconf.FromEnvP("MH_MONGO_COLLECTION", "messages").(string), "MongoDB collection, e.g. messages") 127 | flag.StringVar(&cfg.CORSOrigin, "cors-origin", envconf.FromEnvP("MH_CORS_ORIGIN", "").(string), "CORS Access-Control-Allow-Origin header for API endpoints") 128 | flag.StringVar(&cfg.MaildirPath, "maildir-path", envconf.FromEnvP("MH_MAILDIR_PATH", "").(string), "Maildir path (if storage type is 'maildir')") 129 | flag.BoolVar(&cfg.InviteJim, "invite-jim", envconf.FromEnvP("MH_INVITE_JIM", false).(bool), "Decide whether to invite Jim (beware, he causes trouble)") 130 | flag.StringVar(&cfg.OutgoingSMTPFile, "outgoing-smtp", envconf.FromEnvP("MH_OUTGOING_SMTP", "").(string), "JSON file containing outgoing SMTP servers") 131 | Jim.RegisterFlags() 132 | } 133 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | 7 | gohttp "net/http" 8 | 9 | "github.com/ian-kent/go-log/log" 10 | "github.com/mailhog/MailHog-Server/api" 11 | "github.com/mailhog/MailHog-Server/config" 12 | "github.com/mailhog/MailHog-Server/smtp" 13 | "github.com/mailhog/MailHog-UI/assets" 14 | comcfg "github.com/mailhog/MailHog/config" 15 | "github.com/mailhog/http" 16 | ) 17 | 18 | var conf *config.Config 19 | var comconf *comcfg.Config 20 | var exitCh chan int 21 | 22 | func configure() { 23 | comcfg.RegisterFlags() 24 | config.RegisterFlags() 25 | flag.Parse() 26 | conf = config.Configure() 27 | comconf = comcfg.Configure() 28 | } 29 | 30 | func main() { 31 | configure() 32 | 33 | if comconf.AuthFile != "" { 34 | http.AuthFile(comconf.AuthFile) 35 | } 36 | 37 | exitCh = make(chan int) 38 | cb := func(r gohttp.Handler) { 39 | api.CreateAPI(conf, r) 40 | } 41 | go http.Listen(conf.APIBindAddr, assets.Asset, exitCh, cb) 42 | go smtp.Listen(conf, exitCh) 43 | 44 | for { 45 | select { 46 | case <-exitCh: 47 | log.Printf("Received exit signal") 48 | os.Exit(0) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /monkey/jim.go: -------------------------------------------------------------------------------- 1 | package monkey 2 | 3 | import ( 4 | "flag" 5 | "math/rand" 6 | "net" 7 | "time" 8 | 9 | "github.com/ian-kent/linkio" 10 | ) 11 | 12 | // Jim is a chaos monkey 13 | type Jim struct { 14 | DisconnectChance float64 15 | AcceptChance float64 16 | LinkSpeedAffect float64 17 | LinkSpeedMin float64 18 | LinkSpeedMax float64 19 | RejectSenderChance float64 20 | RejectRecipientChance float64 21 | RejectAuthChance float64 22 | logf func(message string, args ...interface{}) 23 | } 24 | 25 | // RegisterFlags implements ChaosMonkey.RegisterFlags 26 | func (j *Jim) RegisterFlags() { 27 | flag.Float64Var(&j.DisconnectChance, "jim-disconnect", 0.005, "Chance of disconnect") 28 | flag.Float64Var(&j.AcceptChance, "jim-accept", 0.99, "Chance of accept") 29 | flag.Float64Var(&j.LinkSpeedAffect, "jim-linkspeed-affect", 0.1, "Chance of affecting link speed") 30 | flag.Float64Var(&j.LinkSpeedMin, "jim-linkspeed-min", 1024, "Minimum link speed (in bytes per second)") 31 | flag.Float64Var(&j.LinkSpeedMax, "jim-linkspeed-max", 10240, "Maximum link speed (in bytes per second)") 32 | flag.Float64Var(&j.RejectSenderChance, "jim-reject-sender", 0.05, "Chance of rejecting a sender (MAIL FROM)") 33 | flag.Float64Var(&j.RejectRecipientChance, "jim-reject-recipient", 0.05, "Chance of rejecting a recipient (RCPT TO)") 34 | flag.Float64Var(&j.RejectAuthChance, "jim-reject-auth", 0.05, "Chance of rejecting authentication (AUTH)") 35 | } 36 | 37 | // Configure implements ChaosMonkey.Configure 38 | func (j *Jim) Configure(logf func(string, ...interface{})) { 39 | j.logf = logf 40 | rand.Seed(time.Now().Unix()) 41 | } 42 | 43 | // ConfigureFrom lets us configure a new Jim from an old one without 44 | // having to expose logf (and any other future private vars) 45 | func (j *Jim) ConfigureFrom(j2 *Jim) { 46 | j.Configure(j2.logf) 47 | } 48 | 49 | // Accept implements ChaosMonkey.Accept 50 | func (j *Jim) Accept(conn net.Conn) bool { 51 | if rand.Float64() > j.AcceptChance { 52 | j.logf("Jim: Rejecting connection\n") 53 | return false 54 | } 55 | j.logf("Jim: Allowing connection\n") 56 | return true 57 | } 58 | 59 | // LinkSpeed implements ChaosMonkey.LinkSpeed 60 | func (j *Jim) LinkSpeed() *linkio.Throughput { 61 | rand.Seed(time.Now().Unix()) 62 | if rand.Float64() < j.LinkSpeedAffect { 63 | lsDiff := j.LinkSpeedMax - j.LinkSpeedMin 64 | lsAffect := j.LinkSpeedMin + (lsDiff * rand.Float64()) 65 | f := linkio.Throughput(lsAffect) * linkio.BytePerSecond 66 | j.logf("Jim: Restricting throughput to %s\n", f) 67 | return &f 68 | } 69 | j.logf("Jim: Allowing unrestricted throughput") 70 | return nil 71 | } 72 | 73 | // ValidRCPT implements ChaosMonkey.ValidRCPT 74 | func (j *Jim) ValidRCPT(rcpt string) bool { 75 | if rand.Float64() < j.RejectRecipientChance { 76 | j.logf("Jim: Rejecting recipient %s\n", rcpt) 77 | return false 78 | } 79 | j.logf("Jim: Allowing recipient%s\n", rcpt) 80 | return true 81 | } 82 | 83 | // ValidMAIL implements ChaosMonkey.ValidMAIL 84 | func (j *Jim) ValidMAIL(mail string) bool { 85 | if rand.Float64() < j.RejectSenderChance { 86 | j.logf("Jim: Rejecting sender %s\n", mail) 87 | return false 88 | } 89 | j.logf("Jim: Allowing sender %s\n", mail) 90 | return true 91 | } 92 | 93 | // ValidAUTH implements ChaosMonkey.ValidAUTH 94 | func (j *Jim) ValidAUTH(mechanism string, args ...string) bool { 95 | if rand.Float64() < j.RejectAuthChance { 96 | j.logf("Jim: Rejecting authentication %s: %s\n", mechanism, args) 97 | return false 98 | } 99 | j.logf("Jim: Allowing authentication %s: %s\n", mechanism, args) 100 | return true 101 | } 102 | 103 | // Disconnect implements ChaosMonkey.Disconnect 104 | func (j *Jim) Disconnect() bool { 105 | if rand.Float64() < j.DisconnectChance { 106 | j.logf("Jim: Being nasty, kicking them off\n") 107 | return true 108 | } 109 | j.logf("Jim: Being nice, letting them stay\n") 110 | return false 111 | } 112 | -------------------------------------------------------------------------------- /monkey/monkey.go: -------------------------------------------------------------------------------- 1 | package monkey 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/ian-kent/linkio" 7 | ) 8 | 9 | // ChaosMonkey should be implemented by chaos monkeys! 10 | type ChaosMonkey interface { 11 | RegisterFlags() 12 | Configure(func(string, ...interface{})) 13 | 14 | // Accept is called for each incoming connection. Returning false closes the connection. 15 | Accept(conn net.Conn) bool 16 | // LinkSpeed sets the maximum connection throughput (in one direction) 17 | LinkSpeed() *linkio.Throughput 18 | 19 | // ValidRCPT is called for the RCPT command. Returning false signals an invalid recipient. 20 | ValidRCPT(rcpt string) bool 21 | // ValidMAIL is called for the MAIL command. Returning false signals an invalid sender. 22 | ValidMAIL(mail string) bool 23 | // ValidAUTH is called after authentication. Returning false signals invalid authentication. 24 | ValidAUTH(mechanism string, args ...string) bool 25 | 26 | // Disconnect is called after every read. Returning true will close the connection. 27 | Disconnect() bool 28 | } 29 | -------------------------------------------------------------------------------- /smtp/session.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // http://www.rfc-editor.org/rfc/rfc5321.txt 4 | 5 | import ( 6 | "io" 7 | "log" 8 | "strings" 9 | 10 | "github.com/ian-kent/linkio" 11 | "github.com/mailhog/MailHog-Server/monkey" 12 | "github.com/mailhog/data" 13 | "github.com/mailhog/smtp" 14 | "github.com/mailhog/storage" 15 | ) 16 | 17 | // Session represents a SMTP session using net.TCPConn 18 | type Session struct { 19 | conn io.ReadWriteCloser 20 | proto *smtp.Protocol 21 | storage storage.Storage 22 | messageChan chan *data.Message 23 | remoteAddress string 24 | isTLS bool 25 | line string 26 | link *linkio.Link 27 | 28 | reader io.Reader 29 | writer io.Writer 30 | monkey monkey.ChaosMonkey 31 | } 32 | 33 | // Accept starts a new SMTP session using io.ReadWriteCloser 34 | func Accept(remoteAddress string, conn io.ReadWriteCloser, storage storage.Storage, messageChan chan *data.Message, hostname string, monkey monkey.ChaosMonkey) { 35 | defer conn.Close() 36 | 37 | proto := smtp.NewProtocol() 38 | proto.Hostname = hostname 39 | var link *linkio.Link 40 | reader := io.Reader(conn) 41 | writer := io.Writer(conn) 42 | if monkey != nil { 43 | linkSpeed := monkey.LinkSpeed() 44 | if linkSpeed != nil { 45 | link = linkio.NewLink(*linkSpeed * linkio.BytePerSecond) 46 | reader = link.NewLinkReader(io.Reader(conn)) 47 | writer = link.NewLinkWriter(io.Writer(conn)) 48 | } 49 | } 50 | 51 | session := &Session{conn, proto, storage, messageChan, remoteAddress, false, "", link, reader, writer, monkey} 52 | proto.LogHandler = session.logf 53 | proto.MessageReceivedHandler = session.acceptMessage 54 | proto.ValidateSenderHandler = session.validateSender 55 | proto.ValidateRecipientHandler = session.validateRecipient 56 | proto.ValidateAuthenticationHandler = session.validateAuthentication 57 | proto.GetAuthenticationMechanismsHandler = func() []string { return []string{"PLAIN"} } 58 | 59 | session.logf("Starting session") 60 | session.Write(proto.Start()) 61 | for session.Read() == true { 62 | if monkey != nil && monkey.Disconnect != nil && monkey.Disconnect() { 63 | session.conn.Close() 64 | break 65 | } 66 | } 67 | session.logf("Session ended") 68 | } 69 | 70 | func (c *Session) validateAuthentication(mechanism string, args ...string) (errorReply *smtp.Reply, ok bool) { 71 | if c.monkey != nil { 72 | ok := c.monkey.ValidAUTH(mechanism, args...) 73 | if !ok { 74 | // FIXME better error? 75 | return smtp.ReplyUnrecognisedCommand(), false 76 | } 77 | } 78 | return nil, true 79 | } 80 | 81 | func (c *Session) validateRecipient(to string) bool { 82 | if c.monkey != nil { 83 | ok := c.monkey.ValidRCPT(to) 84 | if !ok { 85 | return false 86 | } 87 | } 88 | return true 89 | } 90 | 91 | func (c *Session) validateSender(from string) bool { 92 | if c.monkey != nil { 93 | ok := c.monkey.ValidMAIL(from) 94 | if !ok { 95 | return false 96 | } 97 | } 98 | return true 99 | } 100 | 101 | func (c *Session) acceptMessage(msg *data.SMTPMessage) (id string, err error) { 102 | m := msg.Parse(c.proto.Hostname) 103 | c.logf("Storing message %s", m.ID) 104 | id, err = c.storage.Store(m) 105 | c.messageChan <- m 106 | return 107 | } 108 | 109 | func (c *Session) logf(message string, args ...interface{}) { 110 | message = strings.Join([]string{"[SMTP %s]", message}, " ") 111 | args = append([]interface{}{c.remoteAddress}, args...) 112 | log.Printf(message, args...) 113 | } 114 | 115 | // Read reads from the underlying net.TCPConn 116 | func (c *Session) Read() bool { 117 | buf := make([]byte, 1024) 118 | n, err := c.reader.Read(buf) 119 | 120 | if n == 0 { 121 | c.logf("Connection closed by remote host\n") 122 | io.Closer(c.conn).Close() // not sure this is necessary? 123 | return false 124 | } 125 | 126 | if err != nil { 127 | c.logf("Error reading from socket: %s\n", err) 128 | return false 129 | } 130 | 131 | text := string(buf[0:n]) 132 | logText := strings.Replace(text, "\n", "\\n", -1) 133 | logText = strings.Replace(logText, "\r", "\\r", -1) 134 | c.logf("Received %d bytes: '%s'\n", n, logText) 135 | 136 | c.line += text 137 | 138 | for strings.Contains(c.line, "\r\n") { 139 | line, reply := c.proto.Parse(c.line) 140 | c.line = line 141 | 142 | if reply != nil { 143 | c.Write(reply) 144 | if reply.Status == 221 { 145 | io.Closer(c.conn).Close() 146 | return false 147 | } 148 | } 149 | } 150 | 151 | return true 152 | } 153 | 154 | // Write writes a reply to the underlying net.TCPConn 155 | func (c *Session) Write(reply *smtp.Reply) { 156 | lines := reply.Lines() 157 | for _, l := range lines { 158 | logText := strings.Replace(l, "\n", "\\n", -1) 159 | logText = strings.Replace(logText, "\r", "\\r", -1) 160 | c.logf("Sent %d bytes: '%s'", len(l), logText) 161 | c.writer.Write([]byte(l)) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /smtp/session_test.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "testing" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | 10 | "github.com/mailhog/data" 11 | "github.com/mailhog/storage" 12 | ) 13 | 14 | type fakeRw struct { 15 | _read func(p []byte) (n int, err error) 16 | _write func(p []byte) (n int, err error) 17 | _close func() error 18 | } 19 | 20 | func (rw *fakeRw) Read(p []byte) (n int, err error) { 21 | if rw._read != nil { 22 | return rw._read(p) 23 | } 24 | return 0, nil 25 | } 26 | func (rw *fakeRw) Close() error { 27 | if rw._close != nil { 28 | return rw._close() 29 | } 30 | return nil 31 | } 32 | func (rw *fakeRw) Write(p []byte) (n int, err error) { 33 | if rw._write != nil { 34 | return rw._write(p) 35 | } 36 | return len(p), nil 37 | } 38 | 39 | func TestAccept(t *testing.T) { 40 | Convey("Accept should handle a connection", t, func() { 41 | frw := &fakeRw{} 42 | mChan := make(chan *data.Message) 43 | Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil) 44 | }) 45 | } 46 | 47 | func TestSocketError(t *testing.T) { 48 | Convey("Socket errors should return from Accept", t, func() { 49 | frw := &fakeRw{ 50 | _read: func(p []byte) (n int, err error) { 51 | return -1, errors.New("OINK") 52 | }, 53 | } 54 | mChan := make(chan *data.Message) 55 | Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil) 56 | }) 57 | } 58 | 59 | func TestAcceptMessage(t *testing.T) { 60 | Convey("acceptMessage should be called", t, func() { 61 | mbuf := "EHLO localhost\nMAIL FROM:\nRCPT TO:\nDATA\nHi.\r\n.\r\nQUIT\n" 62 | var rbuf []byte 63 | frw := &fakeRw{ 64 | _read: func(p []byte) (n int, err error) { 65 | if len(p) >= len(mbuf) { 66 | ba := []byte(mbuf) 67 | mbuf = "" 68 | for i, b := range ba { 69 | p[i] = b 70 | } 71 | return len(ba), nil 72 | } 73 | 74 | ba := []byte(mbuf[0:len(p)]) 75 | mbuf = mbuf[len(p):] 76 | for i, b := range ba { 77 | p[i] = b 78 | } 79 | return len(ba), nil 80 | }, 81 | _write: func(p []byte) (n int, err error) { 82 | rbuf = append(rbuf, p...) 83 | return len(p), nil 84 | }, 85 | _close: func() error { 86 | return nil 87 | }, 88 | } 89 | mChan := make(chan *data.Message) 90 | var wg sync.WaitGroup 91 | wg.Add(1) 92 | handlerCalled := false 93 | go func() { 94 | handlerCalled = true 95 | <-mChan 96 | //FIXME breaks some tests (in drone.io) 97 | //m := <-mChan 98 | //So(m, ShouldNotBeNil) 99 | wg.Done() 100 | }() 101 | Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil) 102 | wg.Wait() 103 | So(handlerCalled, ShouldBeTrue) 104 | }) 105 | } 106 | 107 | func TestValidateAuthentication(t *testing.T) { 108 | Convey("validateAuthentication is always successful", t, func() { 109 | c := &Session{} 110 | 111 | err, ok := c.validateAuthentication("OINK") 112 | So(err, ShouldBeNil) 113 | So(ok, ShouldBeTrue) 114 | 115 | err, ok = c.validateAuthentication("OINK", "arg1") 116 | So(err, ShouldBeNil) 117 | So(ok, ShouldBeTrue) 118 | 119 | err, ok = c.validateAuthentication("OINK", "arg1", "arg2") 120 | So(err, ShouldBeNil) 121 | So(ok, ShouldBeTrue) 122 | }) 123 | } 124 | 125 | func TestValidateRecipient(t *testing.T) { 126 | Convey("validateRecipient is always successful", t, func() { 127 | c := &Session{} 128 | 129 | So(c.validateRecipient("OINK"), ShouldBeTrue) 130 | So(c.validateRecipient("foo@bar.mailhog"), ShouldBeTrue) 131 | }) 132 | } 133 | 134 | func TestValidateSender(t *testing.T) { 135 | Convey("validateSender is always successful", t, func() { 136 | c := &Session{} 137 | 138 | So(c.validateSender("OINK"), ShouldBeTrue) 139 | So(c.validateSender("foo@bar.mailhog"), ShouldBeTrue) 140 | }) 141 | } 142 | -------------------------------------------------------------------------------- /smtp/smtp.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net" 7 | 8 | "github.com/mailhog/MailHog-Server/config" 9 | ) 10 | 11 | func Listen(cfg *config.Config, exitCh chan int) *net.TCPListener { 12 | log.Printf("[SMTP] Binding to address: %s\n", cfg.SMTPBindAddr) 13 | ln, err := net.Listen("tcp", cfg.SMTPBindAddr) 14 | if err != nil { 15 | log.Fatalf("[SMTP] Error listening on socket: %s\n", err) 16 | } 17 | defer ln.Close() 18 | 19 | for { 20 | conn, err := ln.Accept() 21 | if err != nil { 22 | log.Printf("[SMTP] Error accepting connection: %s\n", err) 23 | continue 24 | } 25 | 26 | if cfg.Monkey != nil { 27 | ok := cfg.Monkey.Accept(conn) 28 | if !ok { 29 | conn.Close() 30 | continue 31 | } 32 | } 33 | 34 | go Accept( 35 | conn.(*net.TCPConn).RemoteAddr().String(), 36 | io.ReadWriteCloser(conn), 37 | cfg.Storage, 38 | cfg.MessageChan, 39 | cfg.Hostname, 40 | cfg.Monkey, 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /websockets/connection.go: -------------------------------------------------------------------------------- 1 | package websockets 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gorilla/websocket" 7 | ) 8 | 9 | const ( 10 | // Time allowed to write a message to the peer. 11 | writeWait = 10 * time.Second 12 | // Time allowed to read the next pong message from the peer. 13 | pongWait = 60 * time.Second 14 | // Send pings to peer with this period. Must be less than pongWait. 15 | pingPeriod = (pongWait * 9) / 10 16 | // Maximum message size allowed from peer. Set to minimum allowed value as we don't expect the client to send non-control messages. 17 | maxMessageSize = 1 18 | ) 19 | 20 | type connection struct { 21 | hub *Hub 22 | ws *websocket.Conn 23 | send chan interface{} 24 | } 25 | 26 | func (c *connection) readLoop() { 27 | defer func() { 28 | c.hub.unregisterChan <- c 29 | c.ws.Close() 30 | }() 31 | c.ws.SetReadLimit(maxMessageSize) 32 | c.ws.SetReadDeadline(time.Now().Add(pongWait)) 33 | c.ws.SetPongHandler(func(string) error { c.ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) 34 | for { 35 | if _, _, err := c.ws.NextReader(); err != nil { 36 | return 37 | } 38 | } 39 | } 40 | 41 | func (c *connection) writeLoop() { 42 | ticker := time.NewTicker(pingPeriod) 43 | defer func() { 44 | ticker.Stop() 45 | c.ws.Close() 46 | }() 47 | for { 48 | select { 49 | case message, ok := <-c.send: 50 | if !ok { 51 | c.writeControl(websocket.CloseMessage) 52 | return 53 | } 54 | if err := c.writeJSON(message); err != nil { 55 | return 56 | } 57 | case <-ticker.C: 58 | if err := c.writeControl(websocket.PingMessage); err != nil { 59 | return 60 | } 61 | } 62 | } 63 | } 64 | 65 | func (c *connection) writeJSON(message interface{}) error { 66 | c.ws.SetWriteDeadline(time.Now().Add(writeWait)) 67 | return c.ws.WriteJSON(message) 68 | } 69 | 70 | func (c *connection) writeControl(messageType int) error { 71 | c.ws.SetWriteDeadline(time.Now().Add(writeWait)) 72 | return c.ws.WriteMessage(messageType, []byte{}) 73 | } 74 | -------------------------------------------------------------------------------- /websockets/hub.go: -------------------------------------------------------------------------------- 1 | package websockets 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/websocket" 7 | "github.com/ian-kent/go-log/log" 8 | ) 9 | 10 | type Hub struct { 11 | upgrader websocket.Upgrader 12 | connections map[*connection]bool 13 | messages chan interface{} 14 | registerChan chan *connection 15 | unregisterChan chan *connection 16 | } 17 | 18 | func NewHub() *Hub { 19 | hub := &Hub{ 20 | upgrader: websocket.Upgrader{ 21 | ReadBufferSize: 256, 22 | WriteBufferSize: 4096, 23 | CheckOrigin: func(r *http.Request) bool { 24 | return true 25 | }, 26 | }, 27 | connections: make(map[*connection]bool), 28 | messages: make(chan interface{}), 29 | registerChan: make(chan *connection), 30 | unregisterChan: make(chan *connection), 31 | } 32 | go hub.run() 33 | return hub 34 | } 35 | 36 | func (h *Hub) run() { 37 | for { 38 | select { 39 | case c := <-h.registerChan: 40 | h.connections[c] = true 41 | case c := <-h.unregisterChan: 42 | h.unregister(c) 43 | case m := <-h.messages: 44 | for c := range h.connections { 45 | select { 46 | case c.send <- m: 47 | default: 48 | h.unregister(c) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | func (h *Hub) unregister(c *connection) { 56 | if _, ok := h.connections[c]; ok { 57 | close(c.send) 58 | delete(h.connections, c) 59 | } 60 | } 61 | 62 | func (h *Hub) Serve(w http.ResponseWriter, r *http.Request) { 63 | ws, err := h.upgrader.Upgrade(w, r, nil) 64 | if err != nil { 65 | log.Println(err) 66 | return 67 | } 68 | c := &connection{hub: h, ws: ws, send: make(chan interface{}, 256)} 69 | h.registerChan <- c 70 | go c.writeLoop() 71 | go c.readLoop() 72 | } 73 | 74 | func (h *Hub) Broadcast(data interface{}) { 75 | h.messages <- data 76 | } 77 | --------------------------------------------------------------------------------