소스 검색

merge master into dashboard

Jordan Schalm 8 년 전
부모
커밋
31cb5817c8
36개의 변경된 파일5268개의 추가작업 그리고 966개의 파일을 삭제
  1. 1 1
      .travis.gofmt.sh
  2. 4 1
      .travis.yml
  3. 4 1
      Makefile
  4. 68 55
      README.md
  5. 157 0
      backends/abstract.go
  6. 216 0
      backends/backend.go
  7. 25 27
      backends/dummy.go
  8. 320 145
      backends/guerrilla_db_redis.go
  9. 33 0
      backends/guerrilla_db_redis_test.go
  10. 0 109
      backends/util.go
  11. 120 52
      client.go
  12. 3 3
      cmd/guerrillad/root.go
  13. 129 58
      cmd/guerrillad/serve.go
  14. 1046 0
      cmd/guerrillad/serve_test.go
  15. 3 4
      cmd/guerrillad/version.go
  16. 235 11
      config.go
  17. 271 0
      config_test.go
  18. 186 0
      envelope/envelope.go
  19. 0 42
      glide.lock
  20. 4 0
      glide.yaml
  21. 8 4
      goguerrilla.conf.sample
  22. 350 30
      guerrilla.go
  23. 335 0
      log/log.go
  24. 1 1
      mocks/client.go
  25. 101 0
      mocks/conn_mock.go
  26. 0 60
      models.go
  27. 33 20
      pool.go
  28. 454 0
      response/enhanced.go
  29. 63 0
      response/enhanced_test.go
  30. 160 0
      response/quote.go
  31. 254 164
      server.go
  32. 102 0
      server_test.go
  33. 47 0
      tests/client.go
  34. 520 166
      tests/guerrilla_test.go
  35. 6 6
      tests/testcert/generate_cert.go
  36. 9 6
      util.go

+ 1 - 1
.travis.gofmt.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 
-if [[ -n $(find . -path '*/vendor/*' -prune -o -name '*.go' -type f -exec gofmt -l {} \;) ]]; then
+if [[ -n $(find . -path '*/vendor/*' -prune -o -path '*.glide/*' -prune -o -name '*.go' -type f -exec gofmt -l {} \;) ]]; then
     echo "Go code is not formatted:"
     gofmt -d .
     exit 1

+ 4 - 1
.travis.yml

@@ -15,4 +15,7 @@ install:
 script:
   - ./.travis.gofmt.sh
   - make guerrillad
-  - go test ./tests
+  - go test ./tests
+  - go test
+  - go test ./cmd/guerrillad
+  - go test ./response

+ 4 - 1
Makefile

@@ -25,4 +25,7 @@ guerrillad: *.go */*.go */*/*.go
 	$(GO_VARS) $(GO) build -o="guerrillad" -ldflags="$(LD_FLAGS)" $(ROOT)/cmd/guerrillad
 
 test: *.go */*.go */*/*.go
-	$(GO_VARS) $(GO) test -v ./...
+	$(GO_VARS) $(GO) test -v .
+	$(GO_VARS) $(GO) test -v ./tests
+	$(GO_VARS) $(GO) test -v ./cmd/guerrillad
+	$(GO_VARS) $(GO) test -v ./response

+ 68 - 55
README.md

@@ -49,12 +49,20 @@ including the number of clients, memory usage, graph the number of
 connections/bytes/memory used for the last 24h.
 Show the top source clients by: IP, by domain & by HELO message.
 Using websocket via https & password protected.
+Update: Currently WIP, see branch https://github.com/flashmob/go-guerrilla/tree/dashboard.
 (1 BTC for a successful merge)
 
-- Testing: Automated test that can start the server and test end-to-end
-a few common cases, some unit tests would be good too. Automate to
- run when code is pushed to github
-(0.25 BTC for a successful merge)
+- Fuzz Testing: Using https://github.com/dvyukov/go-fuzz
+Implement a fuzzing client that will send input to the
+server's connection. 
+Maybe another area to fuzz would be the config file, 
+fuzz the config file and then send a sighup to the server to see if it 
+can crash? Please open an issue before to discuss scope
+(0.25 BTC for a successful merge / bugs found.)
+
+- Testing: Add some automated more tests to increase coverage.
+(0.1 BTC for a successful merge, judged to be a satisfactory increase
+in coverage. Please open an issue before to discuss scope)
 
 - Profiling: Simulate a configurable number of simultaneous clients 
 (eg 5000) which send commands at random speeds with messages of various 
@@ -62,7 +70,8 @@ lengths. Some connections to use TLS. Some connections may produce
 errors, eg. disconnect randomly after a few commands, issue unexpected
 input or timeout. Provide a report of all the bottlenecks and setup so 
 that the report can be run automatically run when code is pushed to 
-github.
+github. (Flame graph maybe? https://github.com/uber/go-torch 
+Please open an issue before to discuss scope)
 (0.25 BTC)
 
 - Looking for someone to do a code review & possibly fix any tidbits,
@@ -114,7 +123,7 @@ If you want to build on the sample `guerrilla-db-redis` module, setup the follow
 in MySQL:
 
 	CREATE TABLE IF NOT EXISTS `new_mail` (
-	  `mail_id` int(11) NOT NULL auto_increment,
+	  `mail_id` BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
 	  `date` datetime NOT NULL,
 	  `from` varchar(128) character set latin1 NOT NULL,
 	  `to` varchar(128) character set latin1 NOT NULL,
@@ -128,9 +137,8 @@ in MySQL:
 	  `recipient` varchar(128) character set latin1 NOT NULL,
 	  `has_attach` int(11) NOT NULL,
 	  `ip_addr` varchar(15) NOT NULL,
-	  `delivered` bit(1) NOT NULL default b'0',
-	  `attach_info` text NOT NULL,
-	  `dkim_valid` tinyint(4) default NULL,
+	  `return_path` VARCHAR(255) NOT NULL,
+	  `is_tls` BIT(1) DEFAULT b'0' NOT NULL,
 	  PRIMARY KEY  (`mail_id`),
 	  KEY `to` (`to`),
 	  KEY `hash` (`hash`),
@@ -139,7 +147,9 @@ in MySQL:
 
 The above table does not store the body of the email which makes it quick
 to query and join, while the body of the email is fetched from Redis
-if needed.
+for future processing. The `mail` field can contain data in case Redis is down.
+Otherwise, if data is in Redis, the `mail` will be blank, and
+the `body` field will contain the word 'redis'.
 
 You can implement your own saveMail function to use whatever storage /
 backend fits for you. Please share them ^_^, in particular, we would 
@@ -178,13 +188,13 @@ config := &guerrilla.AppConfig{
   AllowedHosts: []string{...}
 }
 backend := &CustomBackend{...}
-app := guerrilla.New(config, backend)
+app, err := guerrilla.New(config, backend)
 ```
 
 ## Start the app.
 `Start` is non-blocking, so make sure the main goroutine is kept busy
 ```go
-app.Start() (startErrors []error)
+startErrors := app.Start()
 ```
 
 ## Shutting down.
@@ -205,50 +215,51 @@ Copy goguerrilla.conf.sample to goguerrilla.conf
     {
         "allowed_hosts": ["guerrillamail.com","guerrillamailblock.com","sharklasers.com","guerrillamail.net","guerrillamail.org"], // What hosts to accept
         "pid_file" : "/var/run/go-guerrilla.pid", // pid = process id, so that other programs can send signals to our server
-                "backend_name": "guerrilla-db-redis", // what backend to use for saving email. See /backends dir
-                "backend_config" :
-                    {
-                        "mysql_db":"gmail_mail",
-                        "mysql_host":"127.0.0.1:3306",
-                        "mysql_pass":"ok",
-                        "mysql_user":"root",
-                        "mail_table":"new_mail",
-                        "redis_interface" : "127.0.0.1:6379",
-                        "redis_expire_seconds" : 7200,
-                        "save_workers_size" : 3,
-                        "primary_mail_host":"sharklasers.com"
-                    },
-                "servers" : [ // the following is an array of objects, each object represents a new server that will be spawned
-                    {
-                        "is_enabled" : true, // boolean
-                        "host_name":"mail.test.com", // the hostname of the server as set by MX record
-                        "max_size": 1000000, // maximum size of an email in bytes
-                        "private_key_file":"/path/to/pem/file/test.com.key",  // full path to pem file private key
-                        "public_key_file":"/path/to/pem/file/test.com.crt", // full path to pem file certificate
-                        "timeout":180, // timeout in number of seconds before an idle connection is closed
-                        "listen_interface":"127.0.0.1:25", // listen on ip and port
-                        "start_tls_on":true, // supports the STARTTLS command?
-                        "tls_always_on":false, // always connect using TLS? If true, start_tls_on will be false
-                        "max_clients": 1000, // max clients at one time
-                        "log_file":"/dev/stdout" // where to log to (currently ignored)
-                    },
-                    // the following is a second server, but listening on port 465 and always using TLS
-                    {
-                        "is_enabled" : true,
-                        "host_name":"mail.test.com",
-                        "max_size":1000000,
-                        "private_key_file":"/path/to/pem/file/test.com.key",
-                        "public_key_file":"/path/to/pem/file/test.com.crt",
-                        "timeout":180,
-                        "listen_interface":"127.0.0.1:465",
-                        "start_tls_on":false,
-                        "tls_always_on":true,
-                        "max_clients":500,
-                        "log_file":"/dev/stdout"
-                    }
-                    // repeat as many servers as you need
-                ]
+        "log_file" : "stderr", // can be "off", "stderr", "stdout" or any path to a file
+        "log_level" : "info", // can be  "debug", "info", "error", "warn", "fatal", "panic"
+        "backend_name": "guerrilla-db-redis", // what backend to use for saving email. See /backends dir
+        "backend_config" :
+            {
+                "mysql_db":"gmail_mail",
+                "mysql_host":"127.0.0.1:3306",
+                "mysql_pass":"ok",
+                "mysql_user":"root",
+                "mail_table":"new_mail",
+                "redis_interface" : "127.0.0.1:6379",
+                "redis_expire_seconds" : 7200,
+                "save_workers_size" : 3,
+                "primary_mail_host":"sharklasers.com"
+            },
+        "servers" : [ // the following is an array of objects, each object represents a new server that will be spawned
+            {
+                "is_enabled" : true, // boolean
+                "host_name":"mail.test.com", // the hostname of the server as set by MX record
+                "max_size": 1000000, // maximum size of an email in bytes
+                "private_key_file":"/path/to/pem/file/test.com.key",  // full path to pem file private key
+                "public_key_file":"/path/to/pem/file/test.com.crt", // full path to pem file certificate
+                "timeout":180, // timeout in number of seconds before an idle connection is closed
+                "listen_interface":"127.0.0.1:25", // listen on ip and port
+                "start_tls_on":true, // supports the STARTTLS command?
+                "tls_always_on":false, // always connect using TLS? If true, start_tls_on will be false
+                "max_clients": 1000, // max clients at one time
+                "log_file":"/dev/stdout" // optional. Can be "off", "stderr", "stdout" or any path to a file. Will use global setting of empty.
+            },
+            // the following is a second server, but listening on port 465 and always using TLS
+            {
+                "is_enabled" : true,
+                "host_name":"mail.test.com",
+                "max_size":1000000,
+                "private_key_file":"/path/to/pem/file/test.com.key",
+                "public_key_file":"/path/to/pem/file/test.com.crt",
+                "timeout":180,
+                "listen_interface":"127.0.0.1:465",
+                "start_tls_on":false,
+                "tls_always_on":true,
+                "max_clients":500
             }
+            // repeat as many servers as you need
+        ]
+    }
     }
 
 The Json parser is very strict on syntax. If there's a parse error and it
@@ -276,6 +287,8 @@ Large refactoring of the code.
 - Logging functionality: logrus is now used for logging. Currently output is going to stdout
 - Incompatible change: Config's allowed_hosts is now an array
 - Incompatible change: The server's command is now a command called `guerrillad`
+- Config re-loading via SIGHUP: reload TLS, add/remove/enable/disable servers, change allowed hosts, timeout.
+- Begin writing automated tests
  
 
 1.5.1 - 4nd Nov 2016 (Latest tagged release)

+ 157 - 0
backends/abstract.go

@@ -0,0 +1,157 @@
+package backends
+
+import (
+	"errors"
+	"fmt"
+	"github.com/flashmob/go-guerrilla/envelope"
+	"reflect"
+	"strings"
+)
+
+type AbstractBackend struct {
+	config abstractConfig
+	extend Backend
+}
+
+type abstractConfig struct {
+	LogReceivedMails bool `json:"log_received_mails"`
+}
+
+// Your backend should implement this method and set b.config field with a custom config struct
+// Therefore, your implementation would have your own custom config type instead of dummyConfig
+func (b *AbstractBackend) loadConfig(backendConfig BackendConfig) (err error) {
+	// Load the backend config for the backend. It has already been unmarshalled
+	// from the main config file 'backend' config "backend_config"
+	// Now we need to convert each type and copy into the dummyConfig struct
+	configType := baseConfig(&abstractConfig{})
+	bcfg, err := b.extractConfig(backendConfig, configType)
+	if err != nil {
+		return err
+	}
+	m := bcfg.(*abstractConfig)
+	b.config = *m
+	return nil
+}
+
+func (b *AbstractBackend) Initialize(config BackendConfig) error {
+	if b.extend != nil {
+		return b.extend.loadConfig(config)
+	}
+	err := b.loadConfig(config)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (b *AbstractBackend) Shutdown() error {
+	if b.extend != nil {
+		return b.extend.Shutdown()
+	}
+	return nil
+}
+
+func (b *AbstractBackend) Process(mail *envelope.Envelope) BackendResult {
+	if b.extend != nil {
+		return b.extend.Process(mail)
+	}
+	mail.ParseHeaders()
+
+	if b.config.LogReceivedMails {
+		mainlog.Infof("Mail from: %s / to: %v", mail.MailFrom.String(), mail.RcptTo)
+		mainlog.Info("Headers are: %s", mail.Header)
+
+	}
+	return NewBackendResult("250 OK")
+}
+
+func (b *AbstractBackend) saveMailWorker(saveMailChan chan *savePayload) {
+	if b.extend != nil {
+		b.extend.saveMailWorker(saveMailChan)
+		return
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			// recover form closed channel
+			fmt.Println("Recovered in f", r)
+		}
+		// close any connections / files
+		// ...
+
+	}()
+	for {
+		payload := <-saveMailChan
+		if payload == nil {
+			mainlog.Debug("No more saveMailChan payload")
+			return
+		}
+		// process the email here
+		result := b.Process(payload.mail)
+		// if all good
+		if result.Code() < 300 {
+			payload.savedNotify <- &saveStatus{nil, "s0m3l337Ha5hva1u3LOL"}
+		} else {
+			payload.savedNotify <- &saveStatus{errors.New(result.String()), "s0m3l337Ha5hva1u3LOL"}
+		}
+
+	}
+}
+
+func (b *AbstractBackend) getNumberOfWorkers() int {
+	if b.extend != nil {
+		return b.extend.getNumberOfWorkers()
+	}
+	return 1
+}
+
+func (b *AbstractBackend) testSettings() error {
+	if b.extend != nil {
+		return b.extend.testSettings()
+	}
+	return nil
+}
+
+// Load the backend config for the backend. It has already been unmarshalled
+// from the main config file 'backend' config "backend_config"
+// Now we need to convert each type and copy into the guerrillaDBAndRedisConfig struct
+// The reason why using reflection is because we'll get a nice error message if the field is missing
+// the alternative solution would be to json.Marshal() and json.Unmarshal() however that will not give us any
+// error messages
+func (h *AbstractBackend) extractConfig(configData BackendConfig, configType baseConfig) (interface{}, error) {
+	// Use reflection so that we can provide a nice error message
+	s := reflect.ValueOf(configType).Elem() // so that we can set the values
+	m := reflect.ValueOf(configType).Elem()
+	t := reflect.TypeOf(configType).Elem()
+	typeOfT := s.Type()
+
+	for i := 0; i < m.NumField(); i++ {
+		f := s.Field(i)
+		// read the tags of the config struct
+		field_name := t.Field(i).Tag.Get("json")
+		if len(field_name) > 0 {
+			// parse the tag to
+			// get the field name from struct tag
+			split := strings.Split(field_name, ",")
+			field_name = split[0]
+		} else {
+			// could have no tag
+			// so use the reflected field name
+			field_name = typeOfT.Field(i).Name
+		}
+		if f.Type().Name() == "int" {
+			if intVal, converted := configData[field_name].(float64); converted {
+				s.Field(i).SetInt(int64(intVal))
+			} else {
+				return configType, convertError("property missing/invalid: '" + field_name + "' of expected type: " + f.Type().Name())
+			}
+		}
+		if f.Type().Name() == "string" {
+			if stringVal, converted := configData[field_name].(string); converted {
+				s.Field(i).SetString(stringVal)
+			} else {
+				return configType, convertError("missing/invalid: '" + field_name + "' of type: " + f.Type().Name())
+			}
+		}
+	}
+	return configType, nil
+}

+ 216 - 0
backends/backend.go

@@ -0,0 +1,216 @@
+package backends
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/flashmob/go-guerrilla/envelope"
+	"github.com/flashmob/go-guerrilla/log"
+	"github.com/flashmob/go-guerrilla/response"
+)
+
+var mainlog log.Logger
+
+// Backends process received mail. Depending on the implementation, they can store mail in the database,
+// write to a file, check for spam, re-transmit to another server, etc.
+// Must return an SMTP message (i.e. "250 OK") and a boolean indicating
+// whether the message was processed successfully.
+type Backend interface {
+	// Public methods
+	Process(*envelope.Envelope) BackendResult
+	Initialize(BackendConfig) error
+	Shutdown() error
+
+	// start save mail worker(s)
+	saveMailWorker(chan *savePayload)
+	// get the number of workers that will be stared
+	getNumberOfWorkers() int
+	// test database settings, permissions, correct paths, etc, before starting workers
+	testSettings() error
+	// parse the configuration files
+	loadConfig(BackendConfig) error
+}
+
+type BackendConfig map[string]interface{}
+
+var backends = map[string]Backend{}
+
+type baseConfig interface{}
+
+type saveStatus struct {
+	err  error
+	hash string
+}
+
+type savePayload struct {
+	mail        *envelope.Envelope
+	from        *envelope.EmailAddress
+	recipient   *envelope.EmailAddress
+	savedNotify chan *saveStatus
+}
+
+// BackendResult represents a response to an SMTP client after receiving DATA.
+// The String method should return an SMTP message ready to send back to the
+// client, for example `250 OK: Message received`.
+type BackendResult interface {
+	fmt.Stringer
+	// Code should return the SMTP code associated with this response, ie. `250`
+	Code() int
+}
+
+// Internal implementation of BackendResult for use by backend implementations.
+type backendResult string
+
+func (br backendResult) String() string {
+	return string(br)
+}
+
+// Parses the SMTP code from the first 3 characters of the SMTP message.
+// Returns 554 if code cannot be parsed.
+func (br backendResult) Code() int {
+	trimmed := strings.TrimSpace(string(br))
+	if len(trimmed) < 3 {
+		return 554
+	}
+	code, err := strconv.Atoi(trimmed[:3])
+	if err != nil {
+		return 554
+	}
+	return code
+}
+
+func NewBackendResult(message string) BackendResult {
+	return backendResult(message)
+}
+
+// A backend gateway is a proxy that implements the Backend interface.
+// It is used to start multiple goroutine workers for saving mail, and then distribute email saving to the workers
+// via a channel. Shutting down via Shutdown() will stop all workers.
+// The rest of this program always talks to the backend via this gateway.
+type BackendGateway struct {
+	AbstractBackend
+	saveMailChan chan *savePayload
+	// waits for backend workers to start/stop
+	wg sync.WaitGroup
+	b  Backend
+	// controls access to state
+	stateGuard sync.Mutex
+	State      backendState
+	config     BackendConfig
+}
+
+// possible values for state
+const (
+	BackendStateRunning = iota
+	BackendStateShuttered
+	BackendStateError
+)
+
+type backendState int
+
+func (s backendState) String() string {
+	return strconv.Itoa(int(s))
+}
+
+// New retrieve a backend specified by the backendName, and initialize it using
+// backendConfig
+func New(backendName string, backendConfig BackendConfig, l log.Logger) (Backend, error) {
+	backend, found := backends[backendName]
+	mainlog = l
+	if !found {
+		return nil, fmt.Errorf("backend %q not found", backendName)
+	}
+	gateway := &BackendGateway{b: backend, config: backendConfig}
+	err := gateway.Initialize(backendConfig)
+	if err != nil {
+		return nil, fmt.Errorf("error while initializing the backend: %s", err)
+	}
+	gateway.State = BackendStateRunning
+	return gateway, nil
+}
+
+// Process distributes an envelope to one of the backend workers
+func (gw *BackendGateway) Process(e *envelope.Envelope) BackendResult {
+	if gw.State != BackendStateRunning {
+		return NewBackendResult(response.Canned.FailBackendNotRunning + gw.State.String())
+	}
+
+	to := e.RcptTo
+	from := e.MailFrom
+
+	// place on the channel so that one of the save mail workers can pick it up
+	// TODO: support multiple recipients
+	savedNotify := make(chan *saveStatus)
+	gw.saveMailChan <- &savePayload{e, &from, &to[0], savedNotify}
+	// wait for the save to complete
+	// or timeout
+	select {
+	case status := <-savedNotify:
+		if status.err != nil {
+			return NewBackendResult(response.Canned.FailBackendTransaction + status.err.Error())
+		}
+		return NewBackendResult(response.Canned.SuccessMessageQueued + status.hash)
+
+	case <-time.After(time.Second * 30):
+		mainlog.Infof("Backend has timed out")
+		return NewBackendResult(response.Canned.FailBackendTimeout)
+	}
+}
+func (gw *BackendGateway) Shutdown() error {
+	gw.stateGuard.Lock()
+	defer gw.stateGuard.Unlock()
+	if gw.State != BackendStateShuttered {
+		err := gw.b.Shutdown()
+		if err == nil {
+			close(gw.saveMailChan) // workers will stop
+			gw.wg.Wait()
+			gw.State = BackendStateShuttered
+		}
+		return err
+	}
+	return nil
+}
+
+// Reinitialize starts up a backend gateway that was shutdown before
+func (gw *BackendGateway) Reinitialize() error {
+	if gw.State != BackendStateShuttered {
+		return errors.New("backend must be in BackendStateshuttered state to Reinitialize")
+	}
+	err := gw.Initialize(gw.config)
+	if err != nil {
+		return fmt.Errorf("error while initializing the backend: %s", err)
+	}
+	gw.State = BackendStateRunning
+	return err
+}
+
+func (gw *BackendGateway) Initialize(cfg BackendConfig) error {
+	err := gw.b.Initialize(cfg)
+	if err == nil {
+		workersSize := gw.b.getNumberOfWorkers()
+		if workersSize < 1 {
+			gw.State = BackendStateError
+			return errors.New("Must have at least 1 worker")
+		}
+		if err := gw.b.testSettings(); err != nil {
+			gw.State = BackendStateError
+			return err
+		}
+		gw.saveMailChan = make(chan *savePayload, workersSize)
+		// start our savemail workers
+		gw.wg.Add(workersSize)
+		for i := 0; i < workersSize; i++ {
+			go func() {
+				gw.b.saveMailWorker(gw.saveMailChan)
+				gw.wg.Done()
+			}()
+		}
+	} else {
+		gw.State = BackendStateError
+	}
+	return err
+}

+ 25 - 27
backends/dummy.go

@@ -1,39 +1,37 @@
 package backends
 
-import (
-	log "github.com/Sirupsen/logrus"
-
-	"github.com/flashmob/go-guerrilla"
-)
-
-type DummyBackend struct {
-	config dummyConfig
+func init() {
+	// decorator pattern
+	backends["dummy"] = &AbstractBackend{
+		extend: &DummyBackend{},
+	}
 }
 
+// custom configuration we will parse from the json
+// see guerrillaDBAndRedisConfig struct for a more complete example
 type dummyConfig struct {
 	LogReceivedMails bool `json:"log_received_mails"`
 }
 
-func (b *DummyBackend) loadConfig(config map[string]interface{}) {
-	willLog, ok := config["log_received_mails"].(bool)
-	if !ok {
-		b.config = dummyConfig{false}
-	} else {
-		b.config = dummyConfig{willLog}
-	}
-}
-
-func (b *DummyBackend) Initialize(config map[string]interface{}) {
-	b.loadConfig(config)
-}
-
-func (b *DummyBackend) Shutdown() error {
-	return nil
+// putting all the paces we need together
+type DummyBackend struct {
+	config dummyConfig
+	// embed functions form AbstractBackend so that DummyBackend satisfies the Backend interface
+	AbstractBackend
 }
 
-func (b *DummyBackend) Process(mail *guerrilla.Envelope) guerrilla.BackendResult {
-	if b.config.LogReceivedMails {
-		log.Infof("Mail from: %s / to: %v", mail.MailFrom.String(), mail.RcptTo)
+// Backends should implement this method and set b.config field with a custom config struct
+// Therefore, your implementation would have a custom config type instead of dummyConfig
+func (b *DummyBackend) loadConfig(backendConfig BackendConfig) (err error) {
+	// Load the backend config for the backend. It has already been unmarshalled
+	// from the main config file 'backend' config "backend_config"
+	// Now we need to convert each type and copy into the dummyConfig struct
+	configType := baseConfig(&dummyConfig{})
+	bcfg, err := b.extractConfig(backendConfig, configType)
+	if err != nil {
+		return err
 	}
-	return guerrilla.NewBackendResult("250 OK")
+	m := bcfg.(*dummyConfig)
+	b.config = *m
+	return nil
 }

+ 320 - 145
backends/guerrilla_db_redis.go

@@ -1,26 +1,67 @@
 package backends
 
+// This backend is presented here as an example only, please modify it to your needs.
+// The backend stores the email data in Redis.
+// Other meta-information is stored in MySQL to be joined later.
+// A lot of email gets discarded without viewing on Guerrilla Mail,
+// so it's much faster to put in Redis, where other programs can
+// process it later, without touching the disk.
+//
+// Some features:
+// - It batches the SQL inserts into a single query and inserts either after a time threshold or if the batch is full
+// - If the mysql driver crashes, it's able to recover, log the incident and resume again.
+// - It also does a clean shutdown - it tries to save everything before returning
+//
+// Short history:
+// Started with issuing an insert query for each single email and another query to update the tally
+// Then applied the following optimizations:
+// - Moved tally updates to another background process which does the tallying in a single query
+// - Changed the MySQL queries to insert in batch
+// - Made a Compressor that recycles buffers using sync.Pool
+// The result was around 400% speed improvement. If you know of any more improvements, please share!
+// - Added the recovery mechanism,
+
 import (
-	"encoding/json"
-	"errors"
 	"fmt"
-	"sync"
+
 	"time"
 
-	log "github.com/Sirupsen/logrus"
-	"github.com/flashmob/go-guerrilla"
 	"github.com/garyburd/redigo/redis"
 
-	"github.com/ziutek/mymysql/autorc"
-	_ "github.com/ziutek/mymysql/godrv"
+	"bytes"
+	"compress/zlib"
+	"database/sql"
+	_ "github.com/go-sql-driver/mysql"
+
+	"github.com/go-sql-driver/mysql"
+	"io"
+	"runtime/debug"
+	"strings"
+	"sync"
 )
 
+// how many rows to batch at a time
+const GuerrillaDBAndRedisBatchMax = 2
+
+// tick on every...
+const GuerrillaDBAndRedisBatchTimeout = time.Second * 3
+
+func init() {
+	backends["guerrilla-db-redis"] = &AbstractBackend{
+		extend: &GuerrillaDBAndRedisBackend{}}
+}
+
 type GuerrillaDBAndRedisBackend struct {
-	config       guerrillaDBAndRedisConfig
-	saveMailChan chan *savePayload
-	wg           sync.WaitGroup
+	AbstractBackend
+	config    guerrillaDBAndRedisConfig
+	batcherWg sync.WaitGroup
+	// cache prepared queries
+	cache stmtCache
 }
 
+// statement cache. It's an array, not slice
+type stmtCache [GuerrillaDBAndRedisBatchMax]*sql.Stmt
+
 type guerrillaDBAndRedisConfig struct {
 	NumberOfWorkers    int    `json:"save_workers_size"`
 	MysqlTable         string `json:"mail_table"`
@@ -40,146 +81,292 @@ func convertError(name string) error {
 // Load the backend config for the backend. It has already been unmarshalled
 // from the main config file 'backend' config "backend_config"
 // Now we need to convert each type and copy into the guerrillaDBAndRedisConfig struct
-func (g *GuerrillaDBAndRedisBackend) loadConfig(backendConfig map[string]interface{}) error {
-	data, err := json.Marshal(backendConfig)
+func (g *GuerrillaDBAndRedisBackend) loadConfig(backendConfig BackendConfig) (err error) {
+	configType := baseConfig(&guerrillaDBAndRedisConfig{})
+	bcfg, err := g.extractConfig(backendConfig, configType)
 	if err != nil {
 		return err
 	}
+	m := bcfg.(*guerrillaDBAndRedisConfig)
+	g.config = *m
+	return nil
+}
 
-	err = json.Unmarshal(data, &g.config)
-	if g.config.NumberOfWorkers < 1 {
-		return errors.New("Must have more than 1 worker")
-	}
+func (g *GuerrillaDBAndRedisBackend) getNumberOfWorkers() int {
+	return g.config.NumberOfWorkers
+}
 
-	return err
+type redisClient struct {
+	isConnected bool
+	conn        redis.Conn
+	time        int
 }
 
-func (g *GuerrillaDBAndRedisBackend) Initialize(backendConfig map[string]interface{}) error {
-	err := g.loadConfig(backendConfig)
-	if err != nil {
-		return err
-	}
+// compressedData struct will be compressed using zlib when printed via fmt
+type compressedData struct {
+	extraHeaders []byte
+	data         *bytes.Buffer
+	pool         *sync.Pool
+}
 
-	if err := g.testDbConnections(); err != nil {
-		return err
+// newCompressedData returns a new CompressedData
+func newCompressedData() *compressedData {
+	var p = sync.Pool{
+		New: func() interface{} {
+			var b bytes.Buffer
+			return &b
+		},
+	}
+	return &compressedData{
+		pool: &p,
 	}
+}
 
-	g.saveMailChan = make(chan *savePayload, g.config.NumberOfWorkers)
+// Set the extraheaders and buffer of data to compress
+func (c *compressedData) set(b []byte, d *bytes.Buffer) {
+	c.extraHeaders = b
+	c.data = d
+}
 
-	// start some savemail workers
-	g.wg.Add(g.config.NumberOfWorkers)
-	for i := 0; i < g.config.NumberOfWorkers; i++ {
-		go g.saveMail()
+// implement Stringer interface
+func (c *compressedData) String() string {
+	if c.data == nil {
+		return ""
 	}
+	//borrow a buffer form the pool
+	b := c.pool.Get().(*bytes.Buffer)
+	// put back in the pool
+	defer func() {
+		b.Reset()
+		c.pool.Put(b)
+	}()
 
-	return nil
+	var r *bytes.Reader
+	w, _ := zlib.NewWriterLevel(b, zlib.BestSpeed)
+	r = bytes.NewReader(c.extraHeaders)
+	io.Copy(w, r)
+	io.Copy(w, c.data)
+	w.Close()
+	return b.String()
 }
 
-func (g *GuerrillaDBAndRedisBackend) Shutdown() error {
-	close(g.saveMailChan) // workers will stop
-	g.wg.Wait()
-	return nil
+// clear it, without clearing the pool
+func (c *compressedData) clear() {
+	c.extraHeaders = []byte{}
+	c.data = nil
 }
 
-func (g *GuerrillaDBAndRedisBackend) Process(mail *guerrilla.Envelope) guerrilla.BackendResult {
-	to := mail.RcptTo
-	from := mail.MailFrom
-	if len(to) == 0 {
-		return guerrilla.NewBackendResult("554 Error: no recipient")
+// prepares the sql query with the number of rows that can be batched with it
+func (g *GuerrillaDBAndRedisBackend) prepareInsertQuery(rows int, db *sql.DB) *sql.Stmt {
+	if rows == 0 {
+		panic("rows argument cannot be 0")
+	}
+	if g.cache[rows-1] != nil {
+		return g.cache[rows-1]
 	}
+	sqlstr := "INSERT INTO " + g.config.MysqlTable + " "
+	sqlstr += "(`date`, `to`, `from`, `subject`, `body`, `charset`, `mail`, `spam_score`, `hash`, `content_type`, `recipient`, `has_attach`, `ip_addr`, `return_path`, `is_tls`)"
+	sqlstr += " values "
+	values := "(NOW(), ?, ?, ?, ? , 'UTF-8' , ?, 0, ?, '', ?, 0, ?, ?, ?)"
+	// add more rows
+	comma := ""
+	for i := 0; i < rows; i++ {
+		sqlstr += comma + values
+		if comma == "" {
+			comma = ","
+		}
+	}
+	stmt, sqlErr := db.Prepare(sqlstr)
+	if sqlErr != nil {
+		mainlog.WithError(sqlErr).Fatalf("failed while db.Prepare(INSERT...)")
+	}
+	// cache it
+	g.cache[rows-1] = stmt
+	return stmt
+}
 
-	// to do: timeout when adding to SaveMailChan
-	// place on the channel so that one of the save mail workers can pick it up
-	// TODO: support multiple recipients
-	savedNotify := make(chan *saveStatus)
-	g.saveMailChan <- &savePayload{mail, from, &to[0], savedNotify}
-	// wait for the save to complete
-	// or timeout
-	select {
-	case status := <-savedNotify:
-		if status.err != nil {
-			return guerrilla.NewBackendResult("554 Error: " + status.err.Error())
+func (g *GuerrillaDBAndRedisBackend) doQuery(c int, db *sql.DB, insertStmt *sql.Stmt, vals *[]interface{}) {
+	var execErr error
+	defer func() {
+		if r := recover(); r != nil {
+			//logln(1, fmt.Sprintf("Recovered in %v", r))
+			mainlog.Error("Recovered form panic:", r, string(debug.Stack()))
+			sum := 0
+			for _, v := range *vals {
+				if str, ok := v.(string); ok {
+					sum = sum + len(str)
+				}
+			}
+			mainlog.Errorf("panic while inserting query [%s] size:%d, err %v", r, sum, execErr)
+			panic("query failed")
 		}
-		return guerrilla.NewBackendResult(fmt.Sprintf("250 OK : queued as %s", status.hash))
-	case <-time.After(time.Second * 30):
-		log.Debug("timeout")
-		return guerrilla.NewBackendResult("554 Error: transaction timeout")
+	}()
+	// prepare the query used to insert when rows reaches batchMax
+	insertStmt = g.prepareInsertQuery(c, db)
+	_, execErr = insertStmt.Exec(*vals...)
+	if execErr != nil {
+		mainlog.WithError(execErr).Error("There was a problem the insert")
 	}
 }
 
-type savePayload struct {
-	mail        *guerrilla.Envelope
-	from        *guerrilla.EmailAddress
-	recipient   *guerrilla.EmailAddress
-	savedNotify chan *saveStatus
+// Batches the rows from the feeder chan in to a single INSERT statement.
+// Execute the batches query when:
+// - number of batched rows reaches a threshold, i.e. count n = threshold
+// - or, no new rows within a certain time, i.e. times out
+// The goroutine can either exit if there's a panic or feeder channel closes
+// it returns feederOk which signals if the feeder chanel was ok (still open) while returning
+// if it feederOk is false, then it means the feeder chanel is closed
+func (g *GuerrillaDBAndRedisBackend) insertQueryBatcher(feeder chan []interface{}, db *sql.DB) (feederOk bool) {
+	// controls shutdown
+	defer g.batcherWg.Done()
+	g.batcherWg.Add(1)
+	// vals is where values are batched to
+	var vals []interface{}
+	// how many rows were batched
+	count := 0
+	// The timer will tick every second.
+	// Interrupting the select clause when there's no data on the feeder channel
+	t := time.NewTimer(GuerrillaDBAndRedisBatchTimeout)
+	// prepare the query used to insert when rows reaches batchMax
+	insertStmt := g.prepareInsertQuery(GuerrillaDBAndRedisBatchMax, db)
+	// inserts executes a batched insert query, clears the vals and resets the count
+	insert := func(c int) {
+		if c > 0 {
+			g.doQuery(c, db, insertStmt, &vals)
+		}
+		vals = nil
+		count = 0
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			mainlog.Error("insertQueryBatcher caught a panic", r)
+		}
+	}()
+	// Keep getting values from feeder and add to batch.
+	// if feeder times out, execute the batched query
+	// otherwise, execute the batched query once it reaches the GuerrillaDBAndRedisBatchMax threshold
+	feederOk = true
+	for {
+		select {
+		// it may panic when reading on a closed feeder channel. feederOK detects if it was closed
+		case row, feederOk := <-feeder:
+			if row == nil {
+				mainlog.Info("Query batchaer exiting")
+				// Insert any remaining rows
+				insert(count)
+				return feederOk
+			}
+			vals = append(vals, row...)
+			count++
+			mainlog.Debug("new feeder row:", row, " cols:", len(row), " count:", count, " worker", workerId)
+			if count >= GuerrillaDBAndRedisBatchMax {
+				insert(GuerrillaDBAndRedisBatchMax)
+			}
+			// stop timer from firing (reset the interrupt)
+			if !t.Stop() {
+				<-t.C
+			}
+			t.Reset(GuerrillaDBAndRedisBatchTimeout)
+		case <-t.C:
+			// anything to insert?
+			if n := len(vals); n > 0 {
+				insert(count)
+			}
+			t.Reset(GuerrillaDBAndRedisBatchTimeout)
+		}
+	}
 }
 
-type saveStatus struct {
-	err  error
-	hash string
+func trimToLimit(str string, limit int) string {
+	ret := strings.TrimSpace(str)
+	if len(str) > limit {
+		ret = str[:limit]
+	}
+	return ret
 }
 
-type redisClient struct {
-	isConnected bool
-	conn        redis.Conn
-	time        int
+var workerId = 0
+
+func (g *GuerrillaDBAndRedisBackend) mysqlConnect() (*sql.DB, error) {
+	conf := mysql.Config{
+		User:         g.config.MysqlUser,
+		Passwd:       g.config.MysqlPass,
+		DBName:       g.config.MysqlDB,
+		Net:          "tcp",
+		Addr:         g.config.MysqlHost,
+		ReadTimeout:  GuerrillaDBAndRedisBatchTimeout + (time.Second * 10),
+		WriteTimeout: GuerrillaDBAndRedisBatchTimeout + (time.Second * 10),
+		Params:       map[string]string{"collation": "utf8_general_ci"},
+	}
+	if db, err := sql.Open("mysql", conf.FormatDSN()); err != nil {
+		mainlog.Error("cannot open mysql", err)
+		return nil, err
+	} else {
+		return db, nil
+	}
+
 }
 
-func (g *GuerrillaDBAndRedisBackend) saveMail() {
+func (g *GuerrillaDBAndRedisBackend) saveMailWorker(saveMailChan chan *savePayload) {
 	var to, body string
-	var err error
 
 	var redisErr error
-	var length int
+
+	workerId++
 
 	redisClient := &redisClient{}
-	db := autorc.New(
-		"tcp",
-		"",
-		g.config.MysqlHost,
-		g.config.MysqlUser,
-		g.config.MysqlPass,
-		g.config.MysqlDB)
-	db.Register("set names utf8")
-	sql := "INSERT INTO " + g.config.MysqlTable + " "
-	sql += "(`date`, `to`, `from`, `subject`, `body`, `charset`, `mail`, `spam_score`, `hash`, `content_type`, `recipient`, `has_attach`, `ip_addr`, `return_path`, `is_tls`)"
-	sql += " values (NOW(), ?, ?, ?, ? , 'UTF-8' , ?, 0, ?, '', ?, 0, ?, ?, ?)"
-	ins, sqlErr := db.Prepare(sql)
-	if sqlErr != nil {
-		log.WithError(sqlErr).Fatalf("failed while db.Prepare(INSERT...)")
-	}
-	sql = "UPDATE gm2_setting SET `setting_value` = `setting_value`+1 WHERE `setting_name`='received_emails' LIMIT 1"
-	incr, sqlErr := db.Prepare(sql)
-	if sqlErr != nil {
-		log.WithError(sqlErr).Fatalf("failed while db.Prepare(UPDATE...)")
+	var db *sql.DB
+	var err error
+	db, err = g.mysqlConnect()
+	if err != nil {
+		mainlog.Fatalf("cannot open mysql: %s", err)
 	}
+
+	// start the query SQL batching where we will send data via the feeder channel
+	feeder := make(chan []interface{}, 1)
+	go func() {
+		for {
+			if feederOK := g.insertQueryBatcher(feeder, db); !feederOK {
+				mainlog.Debug("insertQueryBatcher exited")
+				return
+			}
+			// if insertQueryBatcher panics, it can recover and go in again
+			mainlog.Debug("resuming insertQueryBatcher")
+		}
+
+	}()
+
 	defer func() {
 		if r := recover(); r != nil {
-			// recover form closed channel
-			fmt.Println("Recovered in f", r)
-		}
-		if db.Raw != nil {
-			db.Raw.Close()
+			//recover form closed channel
+			mainlog.Error("panic recovered in saveMailWorker", r)
 		}
+		db.Close()
 		if redisClient.conn != nil {
-			log.Infof("closed redis")
+			mainlog.Infof("closed redis")
 			redisClient.conn.Close()
 		}
+		// close the feeder & wait for query batcher to exit.
+		close(feeder)
+		g.batcherWg.Wait()
 
-		g.wg.Done()
 	}()
-
+	var vals []interface{}
+	data := newCompressedData()
 	//  receives values from the channel repeatedly until it is closed.
+
 	for {
-		payload := <-g.saveMailChan
+		payload := <-saveMailChan
 		if payload == nil {
-			log.Debug("No more saveMailChan payload")
+			mainlog.Debug("No more saveMailChan payload")
 			return
 		}
-		to = payload.recipient.User + "@" + g.config.PrimaryHost
-		length = len(payload.mail.Data)
+		mainlog.Debug("Got mail from chan", payload.mail.RemoteAddress)
+		to = trimToLimit(strings.TrimSpace(payload.recipient.User)+"@"+g.config.PrimaryHost, 255)
+		payload.mail.Helo = trimToLimit(payload.mail.Helo, 255)
+		payload.recipient.Host = trimToLimit(payload.recipient.Host, 255)
 		ts := fmt.Sprintf("%d", time.Now().UnixNano())
-		payload.mail.Subject = MimeHeaderDecode(payload.mail.Subject)
+		payload.mail.ParseHeaders()
 		hash := MD5Hex(
 			to,
 			payload.mail.MailFrom.String(),
@@ -191,51 +378,45 @@ func (g *GuerrillaDBAndRedisBackend) saveMail() {
 		addHead += "Received: from " + payload.mail.Helo + " (" + payload.mail.Helo + "  [" + payload.mail.RemoteAddress + "])\r\n"
 		addHead += "	by " + payload.recipient.Host + " with SMTP id " + hash + "@" + payload.recipient.Host + ";\r\n"
 		addHead += "	" + time.Now().Format(time.RFC1123Z) + "\r\n"
-		// compress to save space
-		payload.mail.Data = Compress(addHead, payload.mail.Data)
+
+		// data will be compressed when printed, with addHead added to beginning
+
+		data.set([]byte(addHead), &payload.mail.Data)
 		body = "gzencode"
+
+		// data will be written to redis - it implements the Stringer interface, redigo uses fmt to
+		// print the data to redis.
+
 		redisErr = redisClient.redisConnection(g.config.RedisInterface)
 		if redisErr == nil {
-			_, doErr := redisClient.conn.Do("SETEX", hash, g.config.RedisExpireSeconds, payload.mail.Data)
+			_, doErr := redisClient.conn.Do("SETEX", hash, g.config.RedisExpireSeconds, data)
 			if doErr == nil {
-				payload.mail.Data = ""
-				body = "redis"
+				body = "redis" // the backend system will know to look in redis for the message data
+				data.clear()   // blank
 			}
 		} else {
-			log.WithError(redisErr).Warn("Error while SETEX on redis")
+			mainlog.WithError(redisErr).Warn("Error while connecting redis")
 		}
-		// bind data to cursor
-		ins.Bind(
-			to,
-			payload.mail.MailFrom.String(),
-			payload.mail.Subject,
+
+		vals = []interface{}{} // clear the vals
+		vals = append(vals,
+			trimToLimit(to, 255),
+			trimToLimit(payload.mail.MailFrom.String(), 255),
+			trimToLimit(payload.mail.Subject, 255),
 			body,
-			payload.mail.Data,
+			data.String(),
 			hash,
-			to,
+			trimToLimit(to, 255),
 			payload.mail.RemoteAddress,
-			payload.mail.MailFrom.String(),
-			payload.mail.TLS,
-		)
-		// save, discard result
-		_, _, err = ins.Exec()
-		if err != nil {
-			errMsg := "Database error while inserting"
-			log.WithError(err).Warn(errMsg)
-			payload.savedNotify <- &saveStatus{errors.New(errMsg), hash}
-		} else {
-			log.Debugf("Email saved %s (len=%d)", hash, length)
-			_, _, err = incr.Exec()
-			if err != nil {
-				log.WithError(err).Warn("Database error while incr count")
-			}
-			payload.savedNotify <- &saveStatus{nil, hash}
-		}
+			trimToLimit(payload.mail.MailFrom.String(), 255),
+			payload.mail.TLS)
+		feeder <- vals
+		payload.savedNotify <- &saveStatus{nil, hash}
+
 	}
 }
 
 func (c *redisClient) redisConnection(redisInterface string) (err error) {
-
 	if c.isConnected == false {
 		c.conn, err = redis.Dial("tcp", redisInterface)
 		if err != nil {
@@ -244,24 +425,18 @@ func (c *redisClient) redisConnection(redisInterface string) (err error) {
 		}
 		c.isConnected = true
 	}
-
 	return nil
 }
 
 // test database connection settings
-func (g *GuerrillaDBAndRedisBackend) testDbConnections() (err error) {
-	db := autorc.New(
-		"tcp",
-		"",
-		g.config.MysqlHost,
-		g.config.MysqlUser,
-		g.config.MysqlPass,
-		g.config.MysqlDB)
-
-	if mysqlErr := db.Raw.Connect(); mysqlErr != nil {
-		err = fmt.Errorf("MySql cannot connect, check your settings: %s", mysqlErr)
+func (g *GuerrillaDBAndRedisBackend) testSettings() (err error) {
+
+	var db *sql.DB
+
+	if db, err = g.mysqlConnect(); err != nil {
+		err = fmt.Errorf("MySql cannot connect, check your settings: %s", err)
 	} else {
-		db.Raw.Close()
+		db.Close()
 	}
 
 	redisClient := &redisClient{}

+ 33 - 0
backends/guerrilla_db_redis_test.go

@@ -0,0 +1,33 @@
+package backends
+
+import (
+	"bytes"
+	"compress/zlib"
+	"fmt"
+	"io"
+	"strings"
+	"testing"
+)
+
+func TestCompressedData(t *testing.T) {
+	var b bytes.Buffer
+	var out bytes.Buffer
+	str := "Hello Hello Hello Hello Hello Hello Hello!"
+	sbj := "Subject:hello\r\n"
+	b.WriteString(str)
+	cd := newCompressedData()
+	cd.set([]byte(sbj), &b)
+
+	// compress
+	fmt.Fprint(&out, cd)
+
+	// decompress
+	var result bytes.Buffer
+	zReader, _ := zlib.NewReader(bytes.NewReader(out.Bytes()))
+	io.Copy(&result, zReader)
+	expect := sbj + str
+	if delta := strings.Compare(expect, result.String()); delta != 0 {
+		t.Error(delta, "compression did match, expected", expect, "but got", result.String())
+	}
+
+}

+ 0 - 109
backends/util.go

@@ -4,16 +4,11 @@ import (
 	"bytes"
 	"compress/zlib"
 	"crypto/md5"
-	"encoding/base64"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/textproto"
 	"regexp"
 	"strings"
-
-	"github.com/sloonz/go-qprintable"
-	"gopkg.in/iconv.v1"
 )
 
 // First capturing group is header name, second is header value.
@@ -40,110 +35,6 @@ func ParseHeaders(mailData string) map[string]string {
 	return headers
 }
 
-var mimeRegex, _ = regexp.Compile(`=\?(.+?)\?([QBqp])\?(.+?)\?=`)
-
-// Decode strings in Mime header format
-// eg. =?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?=
-func MimeHeaderDecode(str string) string {
-
-	matched := mimeRegex.FindAllStringSubmatch(str, -1)
-	var charset, encoding, payload string
-	if matched != nil {
-		for i := 0; i < len(matched); i++ {
-			if len(matched[i]) > 2 {
-				charset = matched[i][1]
-				encoding = strings.ToUpper(matched[i][2])
-				payload = matched[i][3]
-				switch encoding {
-				case "B":
-					str = strings.Replace(
-						str,
-						matched[i][0],
-						MailTransportDecode(payload, "base64", charset),
-						1)
-				case "Q":
-					str = strings.Replace(
-						str,
-						matched[i][0],
-						MailTransportDecode(payload, "quoted-printable", charset),
-						1)
-				}
-			}
-		}
-	}
-	return str
-}
-
-// decode from 7bit to 8bit UTF-8
-// encodingType can be "base64" or "quoted-printable"
-func MailTransportDecode(str string, encodingType string, charset string) string {
-	if charset == "" {
-		charset = "UTF-8"
-	} else {
-		charset = strings.ToUpper(charset)
-	}
-	if encodingType == "base64" {
-		str = fromBase64(str)
-	} else if encodingType == "quoted-printable" {
-		str = fromQuotedP(str)
-	}
-
-	if charset != "UTF-8" {
-		charset = fixCharset(charset)
-		// TODO: remove dependency to os-dependent iconv library
-		if cd, err := iconv.Open("UTF-8", charset); err == nil {
-			defer func() {
-				cd.Close()
-				if r := recover(); r != nil {
-					//logln(1, fmt.Sprintf("Recovered in %v", r))
-				}
-			}()
-			// eg. charset can be "ISO-2022-JP"
-			return cd.ConvString(str)
-		}
-
-	}
-	return str
-}
-
-func fromBase64(data string) string {
-	buf := bytes.NewBufferString(data)
-	decoder := base64.NewDecoder(base64.StdEncoding, buf)
-	res, _ := ioutil.ReadAll(decoder)
-	return string(res)
-}
-
-func fromQuotedP(data string) string {
-	buf := bytes.NewBufferString(data)
-	decoder := qprintable.NewDecoder(qprintable.BinaryEncoding, buf)
-	res, _ := ioutil.ReadAll(decoder)
-	return string(res)
-}
-
-var charsetRegex, _ = regexp.Compile(`[_:.\/\\]`)
-
-func fixCharset(charset string) string {
-	fixed_charset := charsetRegex.ReplaceAllString(charset, "-")
-	// Fix charset
-	// borrowed from http://squirrelmail.svn.sourceforge.net/viewvc/squirrelmail/trunk/squirrelmail/include/languages.php?revision=13765&view=markup
-	// OE ks_c_5601_1987 > cp949
-	fixed_charset = strings.Replace(fixed_charset, "ks-c-5601-1987", "cp949", -1)
-	// Moz x-euc-tw > euc-tw
-	fixed_charset = strings.Replace(fixed_charset, "x-euc", "euc", -1)
-	// Moz x-windows-949 > cp949
-	fixed_charset = strings.Replace(fixed_charset, "x-windows_", "cp", -1)
-	// windows-125x and cp125x charsets
-	fixed_charset = strings.Replace(fixed_charset, "windows-", "cp", -1)
-	// ibm > cp
-	fixed_charset = strings.Replace(fixed_charset, "ibm", "cp", -1)
-	// iso-8859-8-i -> iso-8859-8
-	fixed_charset = strings.Replace(fixed_charset, "iso-8859-8-i", "iso-8859-8", -1)
-	if charset != fixed_charset {
-		return fixed_charset
-	}
-	return charset
-}
-
 // returns an md5 hash as string of hex characters
 func MD5Hex(stringArguments ...string) string {
 	h := md5.New()

+ 120 - 52
client.go

@@ -2,8 +2,13 @@ package guerrilla
 
 import (
 	"bufio"
+	"bytes"
+	"crypto/tls"
+	"fmt"
+	"github.com/flashmob/go-guerrilla/envelope"
+	"github.com/flashmob/go-guerrilla/log"
 	"net"
-	"strings"
+	"net/textproto"
 	"sync"
 	"time"
 )
@@ -25,7 +30,7 @@ const (
 )
 
 type client struct {
-	*Envelope
+	*envelope.Envelope
 	ID          uint64
 	ConnectedAt time.Time
 	KilledAt    time.Time
@@ -34,99 +39,132 @@ type client struct {
 	state        ClientState
 	messagesSent int
 	// Response to be written to the client
-	response  string
-	conn      net.Conn
-	bufin     *smtpBufferedReader
-	bufout    *bufio.Writer
-	timeoutMu sync.Mutex
+	response   bytes.Buffer
+	conn       net.Conn
+	bufin      *smtpBufferedReader
+	bufout     *bufio.Writer
+	smtpReader *textproto.Reader
+	ar         *adjustableLimitedReader
+	// guards access to conn
+	connGuard sync.Mutex
+	log       log.Logger
 }
 
-// Email represents a single SMTP message.
-type Envelope struct {
-	// Remote IP address
-	RemoteAddress string
-	// Message sent in EHLO command
-	Helo string
-	// Sender
-	MailFrom *EmailAddress
-	// Recipients
-	RcptTo  []EmailAddress
-	Data    string
-	Subject string
-	TLS     bool
-}
-
-func NewClient(conn net.Conn, clientID uint64) *client {
-	return &client{
+// Allocate a new client
+func NewClient(conn net.Conn, clientID uint64, logger log.Logger) *client {
+	c := &client{
 		conn: conn,
-		Envelope: &Envelope{
-			RemoteAddress: conn.RemoteAddr().String(),
+		Envelope: &envelope.Envelope{
+			RemoteAddress: getRemoteAddr(conn),
 		},
 		ConnectedAt: time.Now(),
 		bufin:       newSMTPBufferedReader(conn),
 		bufout:      bufio.NewWriter(conn),
 		ID:          clientID,
+		log:         logger,
 	}
+	// used for reading the DATA state
+	c.smtpReader = textproto.NewReader(c.bufin.Reader)
+	return c
 }
 
-func (c *client) responseAdd(r string) {
-	c.response = c.response + r + "\r\n"
+// setResponse adds a response to be written on the next turn
+func (c *client) sendResponse(r ...interface{}) {
+	c.bufout.Reset(c.conn)
+	if c.log.IsDebug() {
+		// us additional buffer so that we can log the response in debug mode only
+		c.response.Reset()
+	}
+	for _, item := range r {
+		switch v := item.(type) {
+		case string:
+			if _, err := c.bufout.WriteString(v); err != nil {
+				c.log.WithError(err).Error("could not write to c.bufout")
+			}
+			if c.log.IsDebug() {
+				c.response.WriteString(v)
+			}
+		case error:
+			if _, err := c.bufout.WriteString(v.Error()); err != nil {
+				c.log.WithError(err).Error("could not write to c.bufout")
+			}
+			if c.log.IsDebug() {
+				c.response.WriteString(v.Error())
+			}
+		case fmt.Stringer:
+			if _, err := c.bufout.WriteString(v.String()); err != nil {
+				c.log.WithError(err).Error("could not write to c.bufout")
+			}
+			if c.log.IsDebug() {
+				c.response.WriteString(v.String())
+			}
+		}
+	}
+	c.bufout.WriteString("\r\n")
+	if c.log.IsDebug() {
+		c.response.WriteString("\r\n")
+	}
 }
 
+// resetTransaction resets the SMTP transaction, ready for the next email (doesn't disconnect)
+// Transaction ends on:
+// -HELO/EHLO/REST command
+// -End of DATA command
+// TLS handhsake
 func (c *client) resetTransaction() {
-	c.MailFrom = &EmailAddress{}
-	c.RcptTo = []EmailAddress{}
-	c.Data = ""
+	c.MailFrom = envelope.EmailAddress{}
+	c.RcptTo = []envelope.EmailAddress{}
+	c.Data.Reset()
 	c.Subject = ""
+	c.Header = nil
 }
 
+// isInTransaction returns true if the connection is inside a transaction.
+// A transaction starts after a MAIL command gets issued by the client.
+// Call resetTransaction to end the transaction
 func (c *client) isInTransaction() bool {
-	isMailFromEmpty := *c.MailFrom == (EmailAddress{})
+	isMailFromEmpty := c.MailFrom == (envelope.EmailAddress{})
 	if isMailFromEmpty {
 		return false
 	}
 	return true
 }
 
+// kill flags the connection to close on the next turn
 func (c *client) kill() {
 	c.KilledAt = time.Now()
 }
 
+// isAlive returns true if the client is to close on the next turn
 func (c *client) isAlive() bool {
 	return c.KilledAt.IsZero()
 }
 
-func (c *client) scanSubject(reply string) {
-	if c.Subject == "" && (len(reply) > 8) {
-		test := strings.ToUpper(reply[0:9])
-		if i := strings.Index(test, "SUBJECT: "); i == 0 {
-			// first line with \r\n
-			c.Subject = reply[9:]
-		}
-	} else if strings.HasSuffix(c.Subject, "\r\n") {
-		// chop off the \r\n
-		c.Subject = c.Subject[0 : len(c.Subject)-2]
-		if (strings.HasPrefix(reply, " ")) || (strings.HasPrefix(reply, "\t")) {
-			// subject is multi-line
-			c.Subject = c.Subject + reply[1:]
-		}
-	}
-}
-
+// setTimeout adjust the timeout on the connection, goroutine safe
 func (c *client) setTimeout(t time.Duration) {
-	defer c.timeoutMu.Unlock()
-	c.timeoutMu.Lock()
+	defer c.connGuard.Unlock()
+	c.connGuard.Lock()
 	if c.conn != nil {
 		c.conn.SetDeadline(time.Now().Add(t * time.Second))
 	}
+}
 
+// closeConn closes a client connection, , goroutine safe
+func (c *client) closeConn() {
+	defer c.connGuard.Unlock()
+	c.connGuard.Lock()
+	c.conn.Close()
+	c.conn = nil
 }
 
+// init is called after the client is borrowed from the pool, to get it ready for the connection
 func (c *client) init(conn net.Conn, clientID uint64) {
 	c.conn = conn
 	// reset our reader & writer
 	c.bufout.Reset(conn)
 	c.bufin.Reset(conn)
+	// reset the data buffer, keep it allocated
+	c.Data.Reset()
 	// reset session data
 	c.state = 0
 	c.KilledAt = time.Time{}
@@ -134,10 +172,40 @@ func (c *client) init(conn net.Conn, clientID uint64) {
 	c.ID = clientID
 	c.TLS = false
 	c.errors = 0
-	c.response = ""
 	c.Helo = ""
+	c.Header = nil
+	c.RemoteAddress = getRemoteAddr(conn)
+
 }
 
+// getID returns the client's unique ID
 func (c *client) getID() uint64 {
 	return c.ID
 }
+
+// UpgradeToTLS upgrades a client connection to TLS
+func (client *client) upgradeToTLS(tlsConfig *tls.Config) error {
+	var tlsConn *tls.Conn
+	// load the config thread-safely
+	tlsConn = tls.Server(client.conn, tlsConfig)
+	// Call handshake here to get any handshake error before reading starts
+	err := tlsConn.Handshake()
+	if err != nil {
+		return err
+	}
+	// convert tlsConn to net.Conn
+	client.conn = net.Conn(tlsConn)
+	client.bufout.Reset(client.conn)
+	client.bufin.Reset(client.conn)
+	client.TLS = true
+	return err
+}
+
+func getRemoteAddr(conn net.Conn) string {
+	if addr, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
+		// we just want the IP (not the port)
+		return addr.IP.String()
+	} else {
+		return conn.RemoteAddr().Network()
+	}
+}

+ 3 - 3
cmd/guerrillad/root.go

@@ -1,7 +1,7 @@
 package main
 
 import (
-	log "github.com/Sirupsen/logrus"
+	"github.com/Sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
 
@@ -23,9 +23,9 @@ func init() {
 		"print out more debug information")
 	rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
 		if verbose {
-			log.SetLevel(log.DebugLevel)
+			logrus.SetLevel(logrus.DebugLevel)
 		} else {
-			log.SetLevel(log.InfoLevel)
+			logrus.SetLevel(logrus.InfoLevel)
 		}
 	}
 }

+ 129 - 58
cmd/guerrillad/serve.go

@@ -8,20 +8,23 @@ import (
 	"os"
 	"os/exec"
 	"os/signal"
+	"reflect"
 	"strconv"
 	"strings"
 	"syscall"
 	"time"
 
-	log "github.com/Sirupsen/logrus"
-	"github.com/spf13/cobra"
-
 	"github.com/flashmob/go-guerrilla"
 	"github.com/flashmob/go-guerrilla/backends"
+	"github.com/flashmob/go-guerrilla/log"
+	"github.com/spf13/cobra"
+)
+
+const (
+	defaultPidFile = "/var/run/go-guerrilla.pid"
 )
 
 var (
-	iface      string
 	configPath string
 	pidFile    string
 
@@ -33,13 +36,20 @@ var (
 
 	cmdConfig     = CmdConfig{}
 	signalChannel = make(chan os.Signal, 1) // for trapping SIG_HUP
+	mainlog       log.Logger
 )
 
 func init() {
+	// log to stderr on startup
+	var logOpenError error
+	if mainlog, logOpenError = log.GetLogger(log.OutputStderr.String()); logOpenError != nil {
+		mainlog.WithError(logOpenError).Errorf("Failed creating a logger to %s", log.OutputStderr)
+	}
 	serveCmd.PersistentFlags().StringVarP(&configPath, "config", "c",
 		"goguerrilla.conf", "Path to the configuration file")
-	serveCmd.PersistentFlags().StringVarP(&pidFile, "pid-file", "p",
-		"/var/run/go-guerrilla.pid", "Path to the pid file")
+	// intentionally didn't specify default pidFile; value from config is used if flag is empty
+	serveCmd.PersistentFlags().StringVarP(&pidFile, "pidFile", "p",
+		"", "Path to the pid file")
 
 	rootCmd.AddCommand(serveCmd)
 }
@@ -49,32 +59,55 @@ func sigHandler(app guerrilla.Guerrilla) {
 	signal.Notify(signalChannel, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGKILL)
 
 	for sig := range signalChannel {
-
 		if sig == syscall.SIGHUP {
-			err := readConfig(configPath, verbose, &cmdConfig)
+			// save old config & load in new one
+			oldConfig := cmdConfig
+			newConfig := CmdConfig{}
+			err := readConfig(configPath, pidFile, &newConfig)
 			if err != nil {
-				log.WithError(err).Error("Error while ReadConfig (reload)")
+				mainlog.WithError(err).Error("Error while ReadConfig (reload)")
 			} else {
-				log.Infof("Configuration is reloaded at %s", guerrilla.ConfigLoadTime)
+				cmdConfig = newConfig
+				mainlog.Infof("Configuration was reloaded at %s", guerrilla.ConfigLoadTime)
+				cmdConfig.emitChangeEvents(&oldConfig, app)
 			}
-			// TODO: reinitialize
 		} else if sig == syscall.SIGTERM || sig == syscall.SIGQUIT || sig == syscall.SIGINT {
-			log.Infof("Shutdown signal caught")
+			mainlog.Infof("Shutdown signal caught")
 			app.Shutdown()
-			log.Infof("Shutdown completd, exiting.")
-			os.Exit(0)
+			mainlog.Infof("Shutdown completed, exiting.")
+			return
 		} else {
-			os.Exit(0)
+			mainlog.Infof("Shutdown, unknown signal caught")
+			return
 		}
 	}
 }
 
+func subscribeBackendEvent(event string, backend backends.Backend, app guerrilla.Guerrilla) {
+
+	app.Subscribe(event, func(cmdConfig *CmdConfig) {
+		logger, _ := log.GetLogger(cmdConfig.LogFile)
+		var err error
+		if err = backend.Shutdown(); err != nil {
+			logger.WithError(err).Warn("Backend failed to shutdown")
+			return
+		}
+		backend, err = backends.New(cmdConfig.BackendName, cmdConfig.BackendConfig, logger)
+		if err != nil {
+			logger.WithError(err).Fatalf("Error while loading the backend %q",
+				cmdConfig.BackendName)
+		} else {
+			logger.Info("Backend started:", cmdConfig.BackendName)
+		}
+	})
+}
+
 func serve(cmd *cobra.Command, args []string) {
 	logVersion()
 
-	err := readConfig(configPath, verbose, &cmdConfig)
+	err := readConfig(configPath, pidFile, &cmdConfig)
 	if err != nil {
-		log.WithError(err).Fatal("Error while reading config")
+		mainlog.WithError(err).Fatal("Error while reading config")
 	}
 
 	// Check that max clients is not greater than system open file limit.
@@ -86,47 +119,46 @@ func serve(cmd *cobra.Command, args []string) {
 			maxClients += s.MaxClients
 		}
 		if maxClients > fileLimit {
-			log.Warnf("Combined max clients for all servers (%d) is greater than open file limit (%d). "+
+			mainlog.Warnf("Combined max clients for all servers (%d) is greater than open file limit (%d). "+
 				"Please increase your open file limit or decrease max clients.", maxClients, fileLimit)
 		}
 	}
 
-	// Write out our PID
-	if len(pidFile) > 0 {
-		if f, err := os.Create(pidFile); err == nil {
-			defer f.Close()
-			if _, err := f.WriteString(fmt.Sprintf("%d", os.Getpid())); err == nil {
-				f.Sync()
-			} else {
-				log.WithError(err).Fatalf("Error while writing pidFile (%s)", pidFile)
-			}
-		} else {
-			log.WithError(err).Fatalf("Error while creating pidFile (%s)", pidFile)
-		}
+	// Backend setup
+	var backend backends.Backend
+	backend, err = backends.New(cmdConfig.BackendName, cmdConfig.BackendConfig, mainlog)
+	if err != nil {
+		mainlog.WithError(err).Fatalf("Error while loading the backend %q",
+			cmdConfig.BackendName)
 	}
-	var backend guerrilla.Backend
-	switch cmdConfig.BackendName {
-	case "dummy":
-		b := &backends.DummyBackend{}
-		b.Initialize(cmdConfig.BackendConfig)
-		backend = guerrilla.Backend(b)
-	case "guerrilla-db-redis":
-		b := &backends.GuerrillaDBAndRedisBackend{}
-		err = b.Initialize(cmdConfig.BackendConfig)
-		if err != nil {
-			log.WithError(err).Errorf("Initalization of %s backend failed", cmdConfig.BackendName)
-		}
 
-		backend = guerrilla.Backend(b)
-	default:
-		log.Fatalf("Unknown backend: %s", cmdConfig.BackendName)
+	app, err := guerrilla.New(&cmdConfig.AppConfig, backend, mainlog)
+	if err != nil {
+		mainlog.WithError(err).Error("Error(s) when creating new server(s)")
 	}
-	b := &backends.GuerrillaDBAndRedisBackend{}
-	err = b.Initialize(cmdConfig.BackendConfig)
 
-	app := guerrilla.New(&cmdConfig.AppConfig, &backend)
-	go app.Start()
+	// start the app
+	err = app.Start()
+	if err != nil {
+		mainlog.WithError(err).Error("Error(s) when starting server(s)")
+	}
+	subscribeBackendEvent("config_change:backend_config", backend, app)
+	subscribeBackendEvent("config_change:backend_name", backend, app)
+	// Write out our PID
+	writePid(cmdConfig.PidFile)
+	// ...and write out our pid whenever the file name changes in the config
+	app.Subscribe("config_change:pid_file", func(ac *guerrilla.AppConfig) {
+		writePid(ac.PidFile)
+	})
+	// change the logger from stdrerr to one from config
+	mainlog.Infof("main log configured to %s", cmdConfig.LogFile)
+	var logOpenError error
+	if mainlog, logOpenError = log.GetLogger(cmdConfig.LogFile); logOpenError != nil {
+		mainlog.WithError(logOpenError).Errorf("Failed changing to a custom logger [%s]", cmdConfig.LogFile)
+	}
+	app.SetLogger(mainlog)
 	sigHandler(app)
+
 }
 
 // Superset of `guerrilla.AppConfig` containing options specific
@@ -134,26 +166,50 @@ func serve(cmd *cobra.Command, args []string) {
 type CmdConfig struct {
 	guerrilla.AppConfig
 	BackendName   string                 `json:"backend_name"`
-	BackendConfig map[string]interface{} `json:"backend_config"`
+	BackendConfig backends.BackendConfig `json:"backend_config"`
+}
+
+func (c *CmdConfig) load(jsonBytes []byte) error {
+	c.AppConfig.Load(jsonBytes)
+	err := json.Unmarshal(jsonBytes, &c)
+	if err != nil {
+		return fmt.Errorf("Could not parse config file: %s", err.Error())
+	}
+	return nil
+}
+
+func (c *CmdConfig) emitChangeEvents(oldConfig *CmdConfig, app guerrilla.Guerrilla) {
+	// has backend changed?
+	if !reflect.DeepEqual((*c).BackendConfig, (*oldConfig).BackendConfig) {
+		app.Publish("config_change:backend_config", c)
+	}
+	if c.BackendName != oldConfig.BackendName {
+		app.Publish("config_change:backend_name", c)
+	}
+	// call other emitChangeEvents
+	c.AppConfig.EmitChangeEvents(&oldConfig.AppConfig, app)
 }
 
 // ReadConfig which should be called at startup, or when a SIG_HUP is caught
-func readConfig(path string, verbose bool, config *CmdConfig) error {
+func readConfig(path string, pidFile string, config *CmdConfig) error {
 	// load in the config.
 	data, err := ioutil.ReadFile(path)
 	if err != nil {
 		return fmt.Errorf("Could not read config file: %s", err.Error())
 	}
-
-	err = json.Unmarshal(data, &config)
-	if err != nil {
-		return fmt.Errorf("Could not parse config file: %s", err.Error())
+	if err := config.load(data); err != nil {
+		return err
+	}
+	// override config pidFile with with flag from the command line
+	if len(pidFile) > 0 {
+		config.AppConfig.PidFile = pidFile
+	} else if len(config.AppConfig.PidFile) == 0 {
+		config.AppConfig.PidFile = defaultPidFile
 	}
 
 	if len(config.AllowedHosts) == 0 {
 		return errors.New("Empty `allowed_hosts` is not allowed")
 	}
-
 	guerrilla.ConfigLoadTime = time.Now()
 	return nil
 }
@@ -164,11 +220,26 @@ func getFileLimit() int {
 	if err != nil {
 		return -1
 	}
-
 	limit, err := strconv.Atoi(strings.TrimSpace(string(out)))
 	if err != nil {
 		return -1
 	}
-
 	return limit
 }
+
+func writePid(pidFile string) {
+	if len(pidFile) > 0 {
+		if f, err := os.Create(pidFile); err == nil {
+			defer f.Close()
+			pid := os.Getpid()
+			if _, err := f.WriteString(fmt.Sprintf("%d", pid)); err == nil {
+				f.Sync()
+				mainlog.Infof("pid_file (%s) written with pid:%v", pidFile, pid)
+			} else {
+				mainlog.WithError(err).Fatalf("Error while writing pidFile (%s)", pidFile)
+			}
+		} else {
+			mainlog.WithError(err).Fatalf("Error while creating pidFile (%s)", pidFile)
+		}
+	}
+}

+ 1046 - 0
cmd/guerrillad/serve_test.go

@@ -0,0 +1,1046 @@
+package main
+
+import (
+	"crypto/tls"
+	"encoding/json"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"runtime"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/flashmob/go-guerrilla"
+	"github.com/flashmob/go-guerrilla/backends"
+	"github.com/flashmob/go-guerrilla/log"
+	test "github.com/flashmob/go-guerrilla/tests"
+	"github.com/flashmob/go-guerrilla/tests/testcert"
+	"github.com/spf13/cobra"
+)
+
+var configJsonA = `
+{
+    "log_file" : "../../tests/testlog",
+    "log_level" : "debug",
+    "pid_file" : "./pidfile.pid",
+    "allowed_hosts": [
+      "guerrillamail.com",
+      "guerrillamailblock.com",
+      "sharklasers.com",
+      "guerrillamail.net",
+      "guerrillamail.org"
+    ],
+    "backend_name": "dummy",
+    "backend_config": {
+        "log_received_mails": true
+    },
+    "servers" : [
+        {
+            "is_enabled" : true,
+            "host_name":"mail.test.com",
+            "max_size": 1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:25",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 1000,
+            "log_file" : "../../tests/testlog"
+        },
+        {
+            "is_enabled" : false,
+            "host_name":"enable.test.com",
+            "max_size": 1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:2228",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 1000,
+            "log_file" : "../../tests/testlog"
+        }
+    ]
+}
+`
+
+// backend config changed, log_received_mails is false
+var configJsonB = `
+{
+    "log_file" : "../../tests/testlog",
+    "log_level" : "debug",
+    "pid_file" : "./pidfile2.pid",
+    "allowed_hosts": [
+      "guerrillamail.com",
+      "guerrillamailblock.com",
+      "sharklasers.com",
+      "guerrillamail.net",
+      "guerrillamail.org"
+    ],
+    "backend_name": "dummy",
+    "backend_config": {
+        "log_received_mails": false
+    },
+    "servers" : [
+        {
+            "is_enabled" : true,
+            "host_name":"mail.test.com",
+            "max_size": 1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:25",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 1000,
+            "log_file" : "../../tests/testlog"
+        }
+    ]
+}
+`
+
+// backend_name changed, is guerrilla-redis-db + added a server
+var configJsonC = `
+{
+    "log_file" : "../../tests/testlog",
+    "log_level" : "debug",
+    "pid_file" : "./pidfile.pid",
+    "allowed_hosts": [
+      "guerrillamail.com",
+      "guerrillamailblock.com",
+      "sharklasers.com",
+      "guerrillamail.net",
+      "guerrillamail.org"
+    ],
+    "backend_name": "guerrilla-redis-db",
+    "backend_config" :
+        {
+            "mysql_db":"gmail_mail",
+            "mysql_host":"127.0.0.1:3306",
+            "mysql_pass":"ok",
+            "mysql_user":"root",
+            "mail_table":"new_mail",
+            "redis_interface" : "127.0.0.1:6379",
+            "redis_expire_seconds" : 7200,
+            "save_workers_size" : 3,
+            "primary_mail_host":"sharklasers.com"
+        },
+    "servers" : [
+        {
+            "is_enabled" : true,
+            "host_name":"mail.test.com",
+            "max_size": 1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:25",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 1000,
+            "log_file" : "../../tests/testlog"
+        },
+        {
+            "is_enabled" : true,
+            "host_name":"mail.test.com",
+            "max_size":1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:465",
+            "start_tls_on":false,
+            "tls_always_on":true,
+            "max_clients":500,
+            "log_file" : "../../tests/testlog"
+        }
+    ]
+}
+`
+
+// adds 127.0.0.1:4655, a secure server
+var configJsonD = `
+{
+    "log_file" : "../../tests/testlog",
+    "log_level" : "debug",
+    "pid_file" : "./pidfile.pid",
+    "allowed_hosts": [
+      "guerrillamail.com",
+      "guerrillamailblock.com",
+      "sharklasers.com",
+      "guerrillamail.net",
+      "guerrillamail.org"
+    ],
+    "backend_name": "dummy",
+    "backend_config": {
+        "log_received_mails": false
+    },
+    "servers" : [
+        {
+            "is_enabled" : true,
+            "host_name":"mail.test.com",
+            "max_size": 1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:2552",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 1000,
+            "log_file" : "../../tests/testlog"
+        },
+        {
+            "is_enabled" : true,
+            "host_name":"secure.test.com",
+            "max_size":1000000,
+            "private_key_file":"../..//tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"../../tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:4655",
+            "start_tls_on":false,
+            "tls_always_on":true,
+            "max_clients":500,
+            "log_file" : "../../tests/testlog"
+        }
+    ]
+}
+`
+
+const testPauseDuration = time.Millisecond * 600
+
+// reload config
+func sigHup() {
+	if data, err := ioutil.ReadFile("pidfile.pid"); err == nil {
+		mainlog.Infof("pid read is %s", data)
+		ecmd := exec.Command("kill", "-HUP", string(data))
+		_, err = ecmd.Output()
+		if err != nil {
+			mainlog.Infof("could not SIGHUP", err)
+		}
+	} else {
+		mainlog.WithError(err).Info("sighup - Could not read pidfle")
+	}
+
+}
+
+// shutdown after calling serve()
+func sigKill() {
+	if data, err := ioutil.ReadFile("pidfile.pid"); err == nil {
+		mainlog.Infof("pid read is %s", data)
+		ecmd := exec.Command("kill", string(data))
+		_, err = ecmd.Output()
+		if err != nil {
+			mainlog.Infof("could not sigkill", err)
+		}
+	} else {
+		mainlog.WithError(err).Info("sigKill - Could not read pidfle")
+	}
+
+}
+
+// make sure that we get all the config change events
+func TestCmdConfigChangeEvents(t *testing.T) {
+
+	oldconf := &CmdConfig{}
+	oldconf.load([]byte(configJsonA))
+
+	newconf := &CmdConfig{}
+	newconf.load([]byte(configJsonB))
+
+	newerconf := &CmdConfig{}
+	newerconf.load([]byte(configJsonC))
+
+	expectedEvents := map[string]bool{
+		"config_change:backend_config": false,
+		"config_change:backend_name":   false,
+		"server_change:new_server":     false,
+	}
+	mainlog, _ = log.GetLogger("off")
+
+	bcfg := backends.BackendConfig{"log_received_mails": true}
+	backend, err := backends.New("dummy", bcfg, mainlog)
+	app, err := guerrilla.New(&oldconf.AppConfig, backend, mainlog)
+	if err != nil {
+		//log.Info("Failed to create new app", err)
+	}
+	toUnsubscribe := map[string]func(c *CmdConfig){}
+	toUnsubscribeS := map[string]func(c *guerrilla.ServerConfig){}
+
+	for event := range expectedEvents {
+		// Put in anon func since range is overwriting event
+		func(e string) {
+
+			if strings.Index(e, "server_change") == 0 {
+				f := func(c *guerrilla.ServerConfig) {
+					expectedEvents[e] = true
+				}
+				app.Subscribe(event, f)
+				toUnsubscribeS[event] = f
+			} else {
+				f := func(c *CmdConfig) {
+					expectedEvents[e] = true
+				}
+				app.Subscribe(event, f)
+				toUnsubscribe[event] = f
+			}
+
+		}(event)
+	}
+
+	// emit events
+	newconf.emitChangeEvents(oldconf, app)
+	newerconf.emitChangeEvents(newconf, app)
+	// unsubscribe
+	for unevent, unfun := range toUnsubscribe {
+		app.Unsubscribe(unevent, unfun)
+	}
+
+	for event, val := range expectedEvents {
+		if val == false {
+			t.Error("Did not fire config change event:", event)
+			t.FailNow()
+			break
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+
+}
+
+// start server, change config, send SIG HUP, confirm that the pidfile changed & backend reloaded
+func TestServe(t *testing.T) {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+
+	ioutil.WriteFile("configJsonA.json", []byte(configJsonA), 0644)
+	cmd := &cobra.Command{}
+	configPath = "configJsonA.json"
+	var serveWG sync.WaitGroup
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+
+	data, err := ioutil.ReadFile("pidfile.pid")
+	if err != nil {
+		t.Error("error reading pidfile.pid", err)
+		t.FailNow()
+	}
+	_, err = strconv.Atoi(string(data))
+	if err != nil {
+		t.Error("could not parse pidfile.pid", err)
+		t.FailNow()
+	}
+
+	// change the config file
+	ioutil.WriteFile("configJsonA.json", []byte(configJsonB), 0644)
+
+	// test SIGHUP via the kill command
+	// Would not work on windows as kill is not available.
+	// TODO: Implement an alternative test for windows.
+	if runtime.GOOS != "windows" {
+		ecmd := exec.Command("kill", "-HUP", string(data))
+		_, err = ecmd.Output()
+		if err != nil {
+			t.Error("could not SIGHUP", err)
+			t.FailNow()
+		}
+		time.Sleep(testPauseDuration) // allow sighup to do its job
+		// did the pidfile change as expected?
+		if _, err := os.Stat("./pidfile2.pid"); os.IsNotExist(err) {
+			t.Error("pidfile not changed after sighup SIGHUP", err)
+		}
+	}
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+
+	// did backend started as expected?
+	fd, err := os.Open("../../tests/testlog")
+	if err != nil {
+		t.Error(err)
+	}
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		if i := strings.Index(logOutput, "Backend started:dummy"); i < 0 {
+			t.Error("Dummy backend not restared")
+		}
+	}
+
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonA.json")
+	os.Remove("./pidfile.pid")
+	os.Remove("./pidfile2.pid")
+
+}
+
+// Start with configJsonA.json,
+// then add a new server to it (127.0.0.1:2526),
+// then SIGHUP (to reload config & trigger config update events),
+// then connect to it & HELO.
+func TestServerAddEvent(t *testing.T) {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonA.json", []byte(configJsonA), 0644)
+	cmd := &cobra.Command{}
+	configPath = "configJsonA.json"
+	var serveWG sync.WaitGroup
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration) // allow the server to start
+	// now change the config by adding a server
+	conf := &CmdConfig{}                                 // blank one
+	conf.load([]byte(configJsonA))                       // load configJsonA
+	newServer := conf.Servers[0]                         // copy the first server config
+	newServer.ListenInterface = "127.0.0.1:2526"         // change it
+	newConf := conf                                      // copy the cmdConfg
+	newConf.Servers = append(newConf.Servers, newServer) // add the new server
+	if jsonbytes, err := json.Marshal(newConf); err == nil {
+		//fmt.Println(string(jsonbytes))
+		ioutil.WriteFile("configJsonA.json", []byte(jsonbytes), 0644)
+	}
+	// send a sighup signal to the server
+	sigHup()
+	time.Sleep(testPauseDuration) // pause for config to reload
+
+	if conn, buffin, err := test.Connect(newServer, 20); err != nil {
+		t.Error("Could not connect to new server", newServer.ListenInterface)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 mail.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			}
+		} else {
+			t.Error(err)
+		}
+	}
+
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "New server added [127.0.0.1:2526]"); i < 0 {
+			t.Error("Did not add [127.0.0.1:2526], most likely because Bus.Subscribe(\"server_change:new_server\" didnt fire")
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonA.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Start with configJsonA.json,
+// then change the config to enable 127.0.0.1:2228,
+// then write the new config,
+// then SIGHUP (to reload config & trigger config update events),
+// then connect to 127.0.0.1:2228 & HELO.
+func TestServerStartEvent(t *testing.T) {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonA.json", []byte(configJsonA), 0644)
+	cmd := &cobra.Command{}
+	configPath = "configJsonA.json"
+	var serveWG sync.WaitGroup
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+	// now change the config by adding a server
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonA)) // load configJsonA
+
+	newConf := conf // copy the cmdConfg
+	newConf.Servers[1].IsEnabled = true
+	if jsonbytes, err := json.Marshal(newConf); err == nil {
+		//fmt.Println(string(jsonbytes))
+		ioutil.WriteFile("configJsonA.json", []byte(jsonbytes), 0644)
+	} else {
+		t.Error(err)
+	}
+	// send a sighup signal to the server
+	sigHup()
+	time.Sleep(testPauseDuration) // pause for config to reload
+
+	if conn, buffin, err := test.Connect(newConf.Servers[1], 20); err != nil {
+		t.Error("Could not connect to new server", newConf.Servers[1].ListenInterface)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 enable.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			}
+		} else {
+			t.Error(err)
+		}
+	}
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "Starting server [127.0.0.1:2228]"); i < 0 {
+			t.Error("did not add [127.0.0.1:2228], most likely because Bus.Subscribe(\"server_change:start_server\" didnt fire")
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonA.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Start with configJsonA.json,
+// then change the config to enable 127.0.0.1:2228,
+// then write the new config,
+// then SIGHUP (to reload config & trigger config update events),
+// then connect to 127.0.0.1:2228 & HELO.
+// then change the config to dsiable 127.0.0.1:2228,
+// then SIGHUP (to reload config & trigger config update events),
+// then connect to 127.0.0.1:2228 - it should not connect
+
+func TestServerStopEvent(t *testing.T) {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonA.json", []byte(configJsonA), 0644)
+	cmd := &cobra.Command{}
+	configPath = "configJsonA.json"
+	var serveWG sync.WaitGroup
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+	// now change the config by enabling a server
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonA)) // load configJsonA
+
+	newConf := conf // copy the cmdConfg
+	newConf.Servers[1].IsEnabled = true
+	if jsonbytes, err := json.Marshal(newConf); err == nil {
+		//fmt.Println(string(jsonbytes))
+		ioutil.WriteFile("configJsonA.json", []byte(jsonbytes), 0644)
+	} else {
+		t.Error(err)
+	}
+	// send a sighup signal to the server
+	sigHup()
+	time.Sleep(testPauseDuration) // pause for config to reload
+
+	if conn, buffin, err := test.Connect(newConf.Servers[1], 20); err != nil {
+		t.Error("Could not connect to new server", newConf.Servers[1].ListenInterface)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 enable.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			}
+		} else {
+			t.Error(err)
+		}
+		conn.Close()
+	}
+	// now disable the server
+	newerConf := newConf // copy the cmdConfg
+	newerConf.Servers[1].IsEnabled = false
+	if jsonbytes, err := json.Marshal(newerConf); err == nil {
+		//fmt.Println(string(jsonbytes))
+		ioutil.WriteFile("configJsonA.json", []byte(jsonbytes), 0644)
+	} else {
+		t.Error(err)
+	}
+	// send a sighup signal to the server
+	sigHup()
+	time.Sleep(testPauseDuration) // pause for config to reload
+
+	// it should not connect to the server
+	if _, _, err := test.Connect(newConf.Servers[1], 20); err == nil {
+		t.Error("127.0.0.1:2228 was disabled, but still accepting connections", newConf.Servers[1].ListenInterface)
+	}
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "Server [127.0.0.1:2228] stopped"); i < 0 {
+			t.Error("did not stop [127.0.0.1:2228], most likely because Bus.Subscribe(\"server_change:stop_server\" didnt fire")
+		}
+	}
+
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonA.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Start with configJsonD.json,
+// then connect to 127.0.0.1:4655 & HELO & try RCPT TO with an invalid host [grr.la]
+// then change the config to enable add new host [grr.la] to allowed_hosts
+// then write the new config,
+// then SIGHUP (to reload config & trigger config update events),
+// connect to 127.0.0.1:4655 & HELO & try RCPT TO, grr.la should work
+
+func TestAllowedHostsEvent(t *testing.T) {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonD.json", []byte(configJsonD), 0644)
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonD)) // load configJsonD
+	cmd := &cobra.Command{}
+	configPath = "configJsonD.json"
+	var serveWG sync.WaitGroup
+	time.Sleep(testPauseDuration)
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+
+	// now connect and try RCPT TO with an invalid host
+	if conn, buffin, err := test.Connect(conf.AppConfig.Servers[1], 20); err != nil {
+		t.Error("Could not connect to new server", conf.AppConfig.Servers[1].ListenInterface, err)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 secure.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			} else {
+				if result, err = test.Command(conn, buffin, "RCPT TO:[email protected]"); err == nil {
+					expect := "454 4.1.1 Error: Relay access denied: grr.la"
+					if strings.Index(result, expect) != 0 {
+						t.Error("Expected:", expect, "but got:", result)
+					}
+				}
+			}
+		}
+		conn.Close()
+	}
+
+	// now change the config by adding a host to allowed hosts
+
+	newConf := conf // copy the cmdConfg
+	newConf.AllowedHosts = append(newConf.AllowedHosts, "grr.la")
+	if jsonbytes, err := json.Marshal(newConf); err == nil {
+		ioutil.WriteFile("configJsonD.json", []byte(jsonbytes), 0644)
+	} else {
+		t.Error(err)
+	}
+	// send a sighup signal to the server to reload config
+	sigHup()
+	time.Sleep(testPauseDuration) // pause for config to reload
+
+	// now repeat the same conversion, RCPT TO should be accepted
+	if conn, buffin, err := test.Connect(conf.AppConfig.Servers[1], 20); err != nil {
+		t.Error("Could not connect to new server", conf.AppConfig.Servers[1].ListenInterface, err)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 secure.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			} else {
+				if result, err = test.Command(conn, buffin, "RCPT TO:[email protected]"); err == nil {
+					expect := "250 2.1.5 OK"
+					if strings.Index(result, expect) != 0 {
+						t.Error("Expected:", expect, "but got:", result)
+					}
+				}
+			}
+		}
+		conn.Close()
+	}
+
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "allowed_hosts config changed, a new list was set"); i < 0 {
+			t.Error("did not change allowed_hosts, most likely because Bus.Subscribe(\"config_change:allowed_hosts\" didnt fire")
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonD.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Test TLS config change event
+// start with configJsonD
+// should be able to STARTTLS to 127.0.0.1:2525 with no problems
+// generate new certs & reload config
+// should get a new tls event & able to STARTTLS with no problem
+
+func TestTLSConfigEvent(t *testing.T) {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	// pause for generated cert to output on slow machines
+	time.Sleep(testPauseDuration)
+	// did cert output?
+	if _, err := os.Stat("../../tests/mail2.guerrillamail.com.cert.pem"); err != nil {
+		t.Error("Did not create cert ", err)
+	}
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonD.json", []byte(configJsonD), 0644)
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonD)) // load configJsonD
+	cmd := &cobra.Command{}
+	configPath = "configJsonD.json"
+	var serveWG sync.WaitGroup
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+
+	// Test STARTTLS handshake
+	testTlsHandshake := func() {
+		if conn, buffin, err := test.Connect(conf.AppConfig.Servers[0], 20); err != nil {
+			t.Error("Could not connect to server", conf.AppConfig.Servers[0].ListenInterface, err)
+		} else {
+			if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+				expect := "250 mail.test.com Hello"
+				if strings.Index(result, expect) != 0 {
+					t.Error("Expected", expect, "but got", result)
+				} else {
+					if result, err = test.Command(conn, buffin, "STARTTLS"); err == nil {
+						expect := "220 2.0.0 Ready to start TLS"
+						if strings.Index(result, expect) != 0 {
+							t.Error("Expected:", expect, "but got:", result)
+						} else {
+							tlsConn := tls.Client(conn, &tls.Config{
+								InsecureSkipVerify: true,
+								ServerName:         "127.0.0.1",
+							})
+							if err := tlsConn.Handshake(); err != nil {
+								t.Error("Failed to handshake", conf.AppConfig.Servers[0].ListenInterface)
+							} else {
+								conn = tlsConn
+								mainlog.Info("TLS Handshake succeeded")
+							}
+
+						}
+					}
+				}
+			}
+			conn.Close()
+		}
+	}
+	testTlsHandshake()
+
+	if err := os.Remove("../../tests/mail2.guerrillamail.com.cert.pem"); err != nil {
+		t.Error("could not remove cert", err)
+	}
+	if err := os.Remove("../../tests/mail2.guerrillamail.com.key.pem"); err != nil {
+		t.Error("could not remove key", err)
+	}
+
+	// generate a new cert
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	// pause for generated cert to output
+	time.Sleep(testPauseDuration)
+	// did cert output?
+	if _, err := os.Stat("../../tests/mail2.guerrillamail.com.cert.pem"); err != nil {
+		t.Error("Did not create cert ", err)
+	}
+
+	sigHup()
+
+	time.Sleep(testPauseDuration * 2) // pause for config to reload
+	testTlsHandshake()
+
+	//time.Sleep(testPauseDuration)
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "Server [127.0.0.1:2552] new TLS configuration loaded"); i < 0 {
+			t.Error("did not change tls, most likely because Bus.Subscribe(\"server_change:tls_config\" didnt fire")
+		}
+	}
+
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonD.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Test for missing TLS certificate, when starting or config reload
+
+func TestBadTLS(t *testing.T) {
+	mainlog, _ = log.GetLogger("../../tests/testlog")
+	if err := os.Remove("./../../tests/mail2.guerrillamail.com.cert.pem"); err != nil {
+		mainlog.WithError(err).Error("could not remove ./../../tests/mail2.guerrillamail.com.cert.pem")
+	} else {
+		mainlog.Info("removed ./../../tests/mail2.guerrillamail.com.cert.pem")
+	}
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonD.json", []byte(configJsonD), 0644)
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonD)) // load configJsonD
+	conf.Servers[0].Timeout = 1
+	cmd := &cobra.Command{}
+	configPath = "configJsonD.json"
+	var serveWG sync.WaitGroup
+
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+
+	// Test STARTTLS handshake
+	testTlsHandshake := func() {
+		if conn, buffin, err := test.Connect(conf.AppConfig.Servers[0], 20); err != nil {
+			t.Error("Could not connect to server", conf.AppConfig.Servers[0].ListenInterface, err)
+		} else {
+			conn.SetDeadline(time.Now().Add(time.Second))
+			if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+				expect := "250 mail.test.com Hello"
+				if strings.Index(result, expect) != 0 {
+					t.Error("Expected", expect, "but got", result)
+				} else {
+					if result, err = test.Command(conn, buffin, "STARTTLS"); err == nil {
+						expect := "220 2.0.0 Ready to start TLS"
+						if strings.Index(result, expect) != 0 {
+							t.Error("Expected:", expect, "but got:", result)
+						} else {
+							tlsConn := tls.Client(conn, &tls.Config{
+								InsecureSkipVerify: true,
+								ServerName:         "127.0.0.1",
+							})
+							if err := tlsConn.Handshake(); err != nil {
+								mainlog.Info("TLS Handshake failed")
+							} else {
+								t.Error("Handshake succeeded, expected it to fail", conf.AppConfig.Servers[0].ListenInterface)
+								conn = tlsConn
+
+							}
+
+						}
+					}
+				}
+			}
+			conn.Close()
+		}
+	}
+	testTlsHandshake()
+
+	// write some trash data
+	ioutil.WriteFile("./../../tests/mail2.guerrillamail.com.cert.pem", []byte("trash data"), 0664)
+	ioutil.WriteFile("./../../tests/mail2.guerrillamail.com.key.pem", []byte("trash data"), 0664)
+
+	// generate a new cert
+	//testcert.GenerateCert("mail2.guerrillamail.com", "", 365 * 24 * time.Hour, false, 2048, "P256", "../../tests/")
+	sigHup()
+
+	time.Sleep(testPauseDuration) // pause for config to reload
+	testTlsHandshake()
+
+	time.Sleep(testPauseDuration)
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "failed to load the new TLS configuration"); i < 0 {
+			t.Error("did not detect TLS load failure")
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonD.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Test for when the server config Timeout value changes
+// Start with configJsonD.json
+
+func TestSetTimeoutEvent(t *testing.T) {
+	//mainlog, _ = log.GetLogger("../../tests/testlog")
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonD.json", []byte(configJsonD), 0644)
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonD)) // load configJsonD
+	cmd := &cobra.Command{}
+	configPath = "configJsonD.json"
+	var serveWG sync.WaitGroup
+
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+
+	if conn, buffin, err := test.Connect(conf.AppConfig.Servers[0], 20); err != nil {
+		t.Error("Could not connect to server", conf.AppConfig.Servers[0].ListenInterface, err)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 mail.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			}
+		}
+	}
+	// set the timeout to 1 second
+
+	newConf := conf // copy the cmdConfg
+	newConf.Servers[0].Timeout = 1
+	if jsonbytes, err := json.Marshal(newConf); err == nil {
+		ioutil.WriteFile("configJsonD.json", []byte(jsonbytes), 0644)
+	} else {
+		t.Error(err)
+	}
+	// send a sighup signal to the server to reload config
+	sigHup()
+	time.Sleep(time.Millisecond * 1200) // pause for connection to timeout
+
+	// so the connection we have opened should timeout by now
+
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "i/o timeout"); i < 0 {
+			t.Error("Connection to 127.0.0.1:2552 didn't timeout as expected")
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonD.json")
+	os.Remove("./pidfile.pid")
+
+}
+
+// Test debug level config change
+// Start in log_level = debug
+// Load config & start server
+func TestDebugLevelChange(t *testing.T) {
+	//mainlog, _ = log.GetLogger("../../tests/testlog")
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "../../tests/")
+	// start the server by emulating the serve command
+	ioutil.WriteFile("configJsonD.json", []byte(configJsonD), 0644)
+	conf := &CmdConfig{}           // blank one
+	conf.load([]byte(configJsonD)) // load configJsonD
+	conf.LogLevel = "debug"
+	cmd := &cobra.Command{}
+	configPath = "configJsonD.json"
+	var serveWG sync.WaitGroup
+
+	serveWG.Add(1)
+	go func() {
+		serve(cmd, []string{})
+		serveWG.Done()
+	}()
+	time.Sleep(testPauseDuration)
+
+	if conn, buffin, err := test.Connect(conf.AppConfig.Servers[0], 20); err != nil {
+		t.Error("Could not connect to server", conf.AppConfig.Servers[0].ListenInterface, err)
+	} else {
+		if result, err := test.Command(conn, buffin, "HELO"); err == nil {
+			expect := "250 mail.test.com Hello"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			}
+		}
+		conn.Close()
+	}
+	// set the log_level to info
+
+	newConf := conf // copy the cmdConfg
+	newConf.LogLevel = "info"
+	if jsonbytes, err := json.Marshal(newConf); err == nil {
+		ioutil.WriteFile("configJsonD.json", []byte(jsonbytes), 0644)
+	} else {
+		t.Error(err)
+	}
+	// send a sighup signal to the server to reload config
+	sigHup()
+	time.Sleep(testPauseDuration) // log to change
+
+	// connect again, this time we should see info
+	if conn, buffin, err := test.Connect(conf.AppConfig.Servers[0], 20); err != nil {
+		t.Error("Could not connect to server", conf.AppConfig.Servers[0].ListenInterface, err)
+	} else {
+		if result, err := test.Command(conn, buffin, "NOOP"); err == nil {
+			expect := "200 2.0.0 OK"
+			if strings.Index(result, expect) != 0 {
+				t.Error("Expected", expect, "but got", result)
+			}
+		}
+		conn.Close()
+	}
+
+	// send kill signal and wait for exit
+	sigKill()
+	serveWG.Wait()
+	// did backend started as expected?
+	fd, _ := os.Open("../../tests/testlog")
+	if read, err := ioutil.ReadAll(fd); err == nil {
+		logOutput := string(read)
+		//fmt.Println(logOutput)
+		if i := strings.Index(logOutput, "log level changed to [info]"); i < 0 {
+			t.Error("Log level did not change to [info]")
+		}
+		// This should not be there:
+		if i := strings.Index(logOutput, "Client sent: NOOP"); i != -1 {
+			t.Error("Log level did not change to [info], we are still seeing debug messages")
+		}
+	}
+	// cleanup
+	os.Truncate("../../tests/testlog", 0)
+	os.Remove("configJsonD.json")
+	os.Remove("./pidfile.pid")
+
+}

+ 3 - 4
cmd/guerrillad/version.go

@@ -1,7 +1,6 @@
 package main
 
 import (
-	log "github.com/Sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	guerrilla "github.com/flashmob/go-guerrilla"
@@ -21,7 +20,7 @@ func init() {
 }
 
 func logVersion() {
-	log.Infof("guerrillad %s", guerrilla.Version)
-	log.Debugf("Build Time: %s", guerrilla.BuildTime)
-	log.Debugf("Commit:     %s", guerrilla.Commit)
+	mainlog.Infof("guerrillad %s", guerrilla.Version)
+	mainlog.Debugf("Build Time: %s", guerrilla.BuildTime)
+	mainlog.Debugf("Commit:     %s", guerrilla.Commit)
 }

+ 235 - 11
config.go

@@ -1,10 +1,22 @@
 package guerrilla
 
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"reflect"
+	"strings"
+)
+
 // AppConfig is the holder of the configuration of the app
 type AppConfig struct {
 	Dashboard    DashboardConfig `json:"dashboard"`
 	Servers      []ServerConfig  `json:"servers"`
 	AllowedHosts []string        `json:"allowed_hosts"`
+	PidFile      string          `json:"pid_file"`
+	LogFile      string          `json:"log_file,omitempty"`
+	LogLevel     string          `json:"log_level,omitempty"`
 }
 
 type DashboardConfig struct {
@@ -14,15 +26,227 @@ type DashboardConfig struct {
 
 // ServerConfig specifies config options for a single server
 type ServerConfig struct {
-	IsEnabled       bool     `json:"is_enabled"`
-	Hostname        string   `json:"host_name"`
-	AllowedHosts    []string `json:"allowed_hosts"`
-	MaxSize         int64    `json:"max_size"`
-	PrivateKeyFile  string   `json:"private_key_file"`
-	PublicKeyFile   string   `json:"public_key_file"`
-	Timeout         int      `json:"timeout"`
-	ListenInterface string   `json:"listen_interface"`
-	StartTLSOn      bool     `json:"start_tls_on,omitempty"`
-	TLSAlwaysOn     bool     `json:"tls_always_on,omitempty"`
-	MaxClients      int      `json:"max_clients"`
+	IsEnabled       bool   `json:"is_enabled"`
+	Hostname        string `json:"host_name"`
+	MaxSize         int64  `json:"max_size"`
+	PrivateKeyFile  string `json:"private_key_file"`
+	PublicKeyFile   string `json:"public_key_file"`
+	Timeout         int    `json:"timeout"`
+	ListenInterface string `json:"listen_interface"`
+	StartTLSOn      bool   `json:"start_tls_on,omitempty"`
+	TLSAlwaysOn     bool   `json:"tls_always_on,omitempty"`
+	MaxClients      int    `json:"max_clients"`
+	LogFile         string `json:"log_file,omitempty"`
+
+	_privateKeyFile_mtime int
+	_publicKeyFile_mtime  int
+}
+
+// Unmarshalls json data into AppConfig struct and any other initialization of the struct
+func (c *AppConfig) Load(jsonBytes []byte) error {
+	err := json.Unmarshal(jsonBytes, c)
+	if err != nil {
+		return fmt.Errorf("could not parse config file: %s", err)
+	}
+	if len(c.AllowedHosts) == 0 {
+		return errors.New("empty AllowedHosts is not allowed")
+	}
+
+	// read the timestamps for the ssl keys, to determine if they need to be reloaded
+	for i := 0; i < len(c.Servers); i++ {
+		if err := c.Servers[i].loadTlsKeyTimestamps(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Emits any configuration change events onto the event bus.
+func (c *AppConfig) EmitChangeEvents(oldConfig *AppConfig, app Guerrilla) {
+	// has 'allowed hosts' changed?
+	if !reflect.DeepEqual(oldConfig.AllowedHosts, c.AllowedHosts) {
+		app.Publish("config_change:allowed_hosts", c)
+	}
+	// has pid file changed?
+	if strings.Compare(oldConfig.PidFile, c.PidFile) != 0 {
+		app.Publish("config_change:pid_file", c)
+	}
+	// has mainlog log changed?
+	if strings.Compare(oldConfig.LogFile, c.LogFile) != 0 {
+		app.Publish("config_change:log_file", c)
+	} else {
+		// since config file has not changed, we reload it
+		app.Publish("config_change:reopen_log_file", c)
+	}
+	// has log level changed?
+	if strings.Compare(oldConfig.LogLevel, c.LogLevel) != 0 {
+		app.Publish("config_change:log_level", c)
+	}
+	// server config changes
+	oldServers := oldConfig.getServers()
+	for iface, newServer := range c.getServers() {
+		// is server is in both configs?
+		if oldServer, ok := oldServers[iface]; ok {
+			// since old server exists in the new config, we do not track it anymore
+			delete(oldServers, iface)
+			newServer.emitChangeEvents(oldServer, app)
+		} else {
+			// start new server
+			app.Publish("server_change:new_server", newServer)
+		}
+
+	}
+	// remove any servers that don't exist anymore
+	for _, oldserver := range oldServers {
+		app.Publish("server_change:remove_server", oldserver)
+	}
+}
+
+// gets the servers in a map (key by interface) for easy lookup
+func (c *AppConfig) getServers() map[string]*ServerConfig {
+	servers := make(map[string]*ServerConfig, len(c.Servers))
+	for i := 0; i < len(c.Servers); i++ {
+		servers[c.Servers[i].ListenInterface] = &c.Servers[i]
+	}
+	return servers
+}
+
+// Emits any configuration change events on the server.
+// All events are fired and run synchronously
+func (sc *ServerConfig) emitChangeEvents(oldServer *ServerConfig, app Guerrilla) {
+	// get a list of changes
+	changes := getDiff(
+		*oldServer,
+		*sc,
+	)
+	if len(changes) > 0 {
+		// something changed in the server config
+		app.Publish("server_change:update_config", sc)
+	}
+
+	// enable or disable?
+	if _, ok := changes["IsEnabled"]; ok {
+		if sc.IsEnabled {
+			app.Publish("server_change:start_server", sc)
+		} else {
+			app.Publish("server_change:stop_server", sc)
+		}
+		// do not emit any more events when IsEnabled changed
+		return
+	}
+	// log file change?
+	if _, ok := changes["LogFile"]; ok {
+		app.Publish("server_change:new_log_file", sc)
+	} else {
+		// since config file has not changed, we reload it
+		app.Publish("server_change:reopen_log_file", sc)
+	}
+	// timeout changed
+	if _, ok := changes["Timeout"]; ok {
+		app.Publish("server_change:timeout", sc)
+	}
+	// max_clients changed
+	if _, ok := changes["MaxClients"]; ok {
+		app.Publish("server_change:max_clients", sc)
+	}
+
+	// tls changed
+	if ok := func() bool {
+		if _, ok := changes["PrivateKeyFile"]; ok {
+			return true
+		}
+		if _, ok := changes["PublicKeyFile"]; ok {
+			return true
+		}
+		if _, ok := changes["StartTLSOn"]; ok {
+			return true
+		}
+		if _, ok := changes["TLSAlwaysOn"]; ok {
+			return true
+		}
+		return false
+	}(); ok {
+		app.Publish("server_change:tls_config", sc)
+	}
+}
+
+// Loads in timestamps for the ssl keys
+func (sc *ServerConfig) loadTlsKeyTimestamps() error {
+	var statErr = func(iface string, err error) error {
+		return errors.New(
+			fmt.Sprintf(
+				"could not stat key for server [%s], %s",
+				iface,
+				err.Error()))
+	}
+	if info, err := os.Stat(sc.PrivateKeyFile); err == nil {
+		sc._privateKeyFile_mtime = info.ModTime().Second()
+	} else {
+		return statErr(sc.ListenInterface, err)
+	}
+	if info, err := os.Stat(sc.PublicKeyFile); err == nil {
+		sc._publicKeyFile_mtime = info.ModTime().Second()
+	} else {
+		return statErr(sc.ListenInterface, err)
+	}
+	return nil
+}
+
+// Gets the timestamp of the TLS certificates. Returns a unix time of when they were last modified
+// when the config was read. We use this info to determine if TLS needs to be re-loaded.
+func (sc *ServerConfig) getTlsKeyTimestamps() (int, int) {
+	return sc._privateKeyFile_mtime, sc._publicKeyFile_mtime
+}
+
+// Returns a diff between struct a & struct b.
+// Results are returned in a map, where each key is the name of the field that was different.
+// a and b are struct values, must not be pointer
+// and of the same struct type
+func getDiff(a interface{}, b interface{}) map[string]interface{} {
+	ret := make(map[string]interface{}, 5)
+	compareWith := structtomap(b)
+	for key, val := range structtomap(a) {
+		if val != compareWith[key] {
+			ret[key] = compareWith[key]
+		}
+	}
+	// detect tls changes (have the key files been modified?)
+	if oldServer, ok := a.(ServerConfig); ok {
+		t1, t2 := oldServer.getTlsKeyTimestamps()
+		if newServer, ok := b.(ServerConfig); ok {
+			t3, t4 := newServer.getTlsKeyTimestamps()
+			if t1 != t3 {
+				ret["PrivateKeyFile"] = newServer.PrivateKeyFile
+			}
+			if t2 != t4 {
+				ret["PublicKeyFile"] = newServer.PublicKeyFile
+			}
+		}
+	}
+	return ret
+}
+
+// Convert fields of a struct to a map
+// only able to convert int, bool and string; not recursive
+func structtomap(obj interface{}) map[string]interface{} {
+	ret := make(map[string]interface{}, 0)
+	v := reflect.ValueOf(obj)
+	t := v.Type()
+	for index := 0; index < v.NumField(); index++ {
+		vField := v.Field(index)
+		fName := t.Field(index).Name
+
+		switch vField.Kind() {
+		case reflect.Int:
+			value := vField.Int()
+			ret[fName] = value
+		case reflect.String:
+			value := vField.String()
+			ret[fName] = value
+		case reflect.Bool:
+			value := vField.Bool()
+			ret[fName] = value
+		}
+	}
+	return ret
 }

+ 271 - 0
config_test.go

@@ -0,0 +1,271 @@
+package guerrilla
+
+import (
+	"github.com/flashmob/go-guerrilla/backends"
+	"github.com/flashmob/go-guerrilla/log"
+	"github.com/flashmob/go-guerrilla/tests/testcert"
+	"io/ioutil"
+	"os"
+	"strings"
+	"testing"
+	"time"
+)
+
+func init() {
+	testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "./tests/")
+}
+
+// a configuration file with a dummy backend
+
+//
+var configJsonA = `
+{
+    "log_file" : "./tests/testlog",
+    "log_level" : "debug",
+    "pid_file" : "/var/run/go-guerrilla.pid",
+    "allowed_hosts": ["spam4.me","grr.la"],
+    "backend_name" : "dummy",
+    "backend_config" :
+        {
+            "log_received_mails" : true
+        },
+    "servers" : [
+        {
+            "is_enabled" : true,
+            "host_name":"mail.guerrillamail.com",
+            "max_size": 100017,
+            "private_key_file":"config_test.go",
+            "public_key_file":"config_test.go",
+            "timeout":160,
+            "listen_interface":"127.0.0.1:2526",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 2
+        },
+
+        {
+            "is_enabled" : true,
+            "host_name":"mail2.guerrillamail.com",
+            "max_size":1000001,
+            "private_key_file":"./tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file":"./tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:2527",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients":1
+        },
+
+        {
+            "is_enabled" : true,
+            "host_name":"mail.stopme.com",
+            "max_size": 100017,
+            "private_key_file":"config_test.go",
+            "public_key_file":"config_test.go",
+            "timeout":160,
+            "listen_interface":"127.0.0.1:9999",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 2
+        },
+
+        {
+            "is_enabled" : true,
+            "host_name":"mail.disableme.com",
+            "max_size": 100017,
+            "private_key_file":"config_test.go",
+            "public_key_file":"config_test.go",
+            "timeout":160,
+            "listen_interface":"127.0.0.1:3333",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 2
+        }
+
+
+    ]
+}
+`
+
+// B is A's configuration with different values from B
+// 127.0.0.1:4654 will be added
+// A's 127.0.0.1:3333 is disabled
+// B's 127.0.0.1:9999 is removed
+
+var configJsonB = `
+{
+    "log_file" : "./tests/testlog",
+    "log_level" : "debug",
+    "pid_file" : "/var/run/different-go-guerrilla.pid",
+    "allowed_hosts": ["spam4.me","grr.la","newhost.com"],
+    "backend_name" : "dummy",
+    "backend_config" :
+        {
+            "log_received_mails" : true
+        },
+    "servers" : [
+        {
+            "is_enabled" : true,
+            "host_name":"mail.guerrillamail.com",
+            "max_size": 100017,
+            "private_key_file":"config_test.go",
+            "public_key_file":"config_test.go",
+            "timeout":161,
+            "listen_interface":"127.0.0.1:2526",
+            "start_tls_on":false,
+            "tls_always_on":true,
+            "max_clients": 3
+        },
+        {
+            "is_enabled" : true,
+            "host_name":"mail2.guerrillamail.com",
+            "max_size": 100017,
+            "private_key_file":"./tests/mail2.guerrillamail.com.key.pem",
+            "public_key_file": "./tests/mail2.guerrillamail.com.cert.pem",
+            "timeout":160,
+            "listen_interface":"127.0.0.1:2527",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 2
+        },
+
+        {
+            "is_enabled" : true,
+            "host_name":"mail.guerrillamail.com",
+            "max_size":1000001,
+            "private_key_file":"config_test.go",
+            "public_key_file":"config_test.go",
+            "timeout":180,
+            "listen_interface":"127.0.0.1:4654",
+            "start_tls_on":false,
+            "tls_always_on":true,
+            "max_clients":1
+        },
+
+        {
+            "is_enabled" : false,
+            "host_name":"mail.disbaleme.com",
+            "max_size": 100017,
+            "private_key_file":"config_test.go",
+            "public_key_file":"config_test.go",
+            "timeout":160,
+            "listen_interface":"127.0.0.1:3333",
+            "start_tls_on":true,
+            "tls_always_on":false,
+            "max_clients": 2
+        }
+    ]
+}
+`
+
+func TestConfigLoad(t *testing.T) {
+	ac := &AppConfig{}
+	if err := ac.Load([]byte(configJsonA)); err != nil {
+		t.Error("Cannot load config |", err)
+		t.SkipNow()
+	}
+	expectedLen := 4
+	if len(ac.Servers) != expectedLen {
+		t.Error("len(ac.Servers), expected", expectedLen, "got", len(ac.Servers))
+		t.SkipNow()
+	}
+	// did we got the timestamps?
+	if ac.Servers[0]._privateKeyFile_mtime <= 0 {
+		t.Error("failed to read timestamp for _privateKeyFile_mtime, got", ac.Servers[0]._privateKeyFile_mtime)
+	}
+}
+
+// Test the sample config to make sure a valid one is given!
+func TestSampleConfig(t *testing.T) {
+	fileName := "goguerrilla.conf.sample"
+	if jsonBytes, err := ioutil.ReadFile(fileName); err == nil {
+		ac := &AppConfig{}
+		if err := ac.Load(jsonBytes); err != nil {
+			// sample config can have broken tls certs
+			if strings.Index(err.Error(), "could not stat key") != 0 {
+				t.Error("Cannot load config", fileName, "|", err)
+				t.FailNow()
+			}
+		}
+	} else {
+		t.Error("Error reading", fileName, "|", err)
+	}
+}
+
+// make sure that we get all the config change events
+func TestConfigChangeEvents(t *testing.T) {
+
+	oldconf := &AppConfig{}
+	oldconf.Load([]byte(configJsonA))
+	logger, _ := log.GetLogger(oldconf.LogFile)
+	bcfg := backends.BackendConfig{"log_received_mails": true}
+	backend, _ := backends.New("dummy", bcfg, logger)
+	app, _ := New(oldconf, backend, logger)
+	// simulate timestamp change
+	time.Sleep(time.Second + time.Millisecond*500)
+	os.Chtimes(oldconf.Servers[1].PrivateKeyFile, time.Now(), time.Now())
+	os.Chtimes(oldconf.Servers[1].PublicKeyFile, time.Now(), time.Now())
+	newconf := &AppConfig{}
+	newconf.Load([]byte(configJsonB))
+	newconf.Servers[0].LogFile = "/dev/stderr" // test for log file change
+	newconf.LogLevel = "off"
+	newconf.LogFile = "off"
+	expectedEvents := map[string]bool{
+		"config_change:pid_file":        false,
+		"config_change:log_file":        false,
+		"config_change:log_level":       false,
+		"config_change:allowed_hosts":   false,
+		"server_change:new_server":      false, // 127.0.0.1:4654 will be added
+		"server_change:remove_server":   false, // 127.0.0.1:9999 server removed
+		"server_change:stop_server":     false, // 127.0.0.1:3333: server (disabled)
+		"server_change:new_log_file":    false, // 127.0.0.1:2526
+		"server_change:reopen_log_file": false, // 127.0.0.1:2527
+		"server_change:timeout":         false, // 127.0.0.1:2526 timeout
+		//"server_change:tls_config":    false, // 127.0.0.1:2526
+		"server_change:max_clients": false, // 127.0.0.1:2526
+		"server_change:tls_config":  false, // 127.0.0.1:2527 timestamp changed on certificates
+	}
+	toUnsubscribe := map[string]func(c *AppConfig){}
+	toUnsubscribeS := map[string]func(c *ServerConfig){}
+
+	for event := range expectedEvents {
+		// Put in anon func since range is overwriting event
+		func(e string) {
+			if strings.Index(e, "config_change") != -1 {
+				f := func(c *AppConfig) {
+					expectedEvents[e] = true
+				}
+				app.Subscribe(event, f)
+				toUnsubscribe[event] = f
+			} else {
+				// must be a server config change then
+				f := func(c *ServerConfig) {
+					expectedEvents[e] = true
+				}
+				app.Subscribe(event, f)
+				toUnsubscribeS[event] = f
+			}
+
+		}(event)
+	}
+
+	// emit events
+	newconf.EmitChangeEvents(oldconf, app)
+	// unsubscribe
+	for unevent, unfun := range toUnsubscribe {
+		app.Unsubscribe(unevent, unfun)
+	}
+	for unevent, unfun := range toUnsubscribeS {
+		app.Unsubscribe(unevent, unfun)
+	}
+	for event, val := range expectedEvents {
+		if val == false {
+			t.Error("Did not fire config change event:", event)
+			t.FailNow()
+			break
+		}
+	}
+
+	// don't forget to reset
+	os.Truncate(oldconf.LogFile, 0)
+}

+ 186 - 0
envelope/envelope.go

@@ -0,0 +1,186 @@
+package envelope
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"github.com/sloonz/go-qprintable"
+	"gopkg.in/iconv.v1"
+	"io/ioutil"
+	"net/textproto"
+	"regexp"
+	"strings"
+)
+
+// EmailAddress encodes an email address of the form `<user@host>`
+type EmailAddress struct {
+	User string
+	Host string
+}
+
+func (ep *EmailAddress) String() string {
+	return fmt.Sprintf("%s@%s", ep.User, ep.Host)
+}
+
+func (ep *EmailAddress) IsEmpty() bool {
+	return ep.User == "" && ep.Host == ""
+}
+
+// Email represents a single SMTP message.
+type Envelope struct {
+	// Remote IP address
+	RemoteAddress string
+	// Message sent in EHLO command
+	Helo string
+	// Sender
+	MailFrom EmailAddress
+	// Recipients
+	RcptTo []EmailAddress
+	// Data stores the header and message body
+	Data bytes.Buffer
+	// Subject stores the subject of the email, extracted and decoded after calling ParseHeaders()
+	Subject string
+	// TLS is true if the email was received using a TLS connection
+	TLS bool
+	// Header stores the results from ParseHeaders()
+	Header textproto.MIMEHeader
+}
+
+// ParseHeaders parses the headers into Header field of the Envelope struct.
+// Data buffer must be full before calling.
+// It assumes that at most 30kb of email data can be a header
+// Decoding of encoding to UTF is only done on the Subject, where the result is assigned to the Subject field
+func (e *Envelope) ParseHeaders() error {
+	var err error
+	if e.Header != nil {
+		return errors.New("Headers already parsed")
+	}
+	all := e.Data.Bytes()
+
+	// find where the header ends, assuming that over 30 kb would be max
+	max := 1024 * 30
+	if len(all) < max {
+		max = len(all) - 1
+	}
+	headerEnd := bytes.Index(all[:max], []byte("\n\n"))
+
+	if headerEnd > -1 {
+		headerReader := textproto.NewReader(bufio.NewReader(bytes.NewBuffer(all[0:headerEnd])))
+		e.Header, err = headerReader.ReadMIMEHeader()
+		if err != nil {
+			// decode the subject
+			if subject, ok := e.Header["Subject"]; ok {
+				e.Subject = MimeHeaderDecode(subject[0])
+			}
+		}
+	} else {
+		err = errors.New("header not found")
+	}
+	return err
+}
+
+var mimeRegex, _ = regexp.Compile(`=\?(.+?)\?([QBqp])\?(.+?)\?=`)
+
+// Decode strings in Mime header format
+// eg. =?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?=
+func MimeHeaderDecode(str string) string {
+
+	matched := mimeRegex.FindAllStringSubmatch(str, -1)
+	var charset, encoding, payload string
+	if matched != nil {
+		for i := 0; i < len(matched); i++ {
+			if len(matched[i]) > 2 {
+				charset = matched[i][1]
+				encoding = strings.ToUpper(matched[i][2])
+				payload = matched[i][3]
+				switch encoding {
+				case "B":
+					str = strings.Replace(
+						str,
+						matched[i][0],
+						MailTransportDecode(payload, "base64", charset),
+						1)
+				case "Q":
+					str = strings.Replace(
+						str,
+						matched[i][0],
+						MailTransportDecode(payload, "quoted-printable", charset),
+						1)
+				}
+			}
+		}
+	}
+	return str
+}
+
+// decode from 7bit to 8bit UTF-8
+// encodingType can be "base64" or "quoted-printable"
+func MailTransportDecode(str string, encodingType string, charset string) string {
+	if charset == "" {
+		charset = "UTF-8"
+	} else {
+		charset = strings.ToUpper(charset)
+	}
+	if encodingType == "base64" {
+		str = fromBase64(str)
+	} else if encodingType == "quoted-printable" {
+		str = fromQuotedP(str)
+	}
+
+	if charset != "UTF-8" {
+		charset = fixCharset(charset)
+		// iconv is pretty good at what it does
+		if cd, err := iconv.Open("UTF-8", charset); err == nil {
+			defer func() {
+				cd.Close()
+				if r := recover(); r != nil {
+					//logln(1, fmt.Sprintf("Recovered in %v", r))
+				}
+			}()
+			// eg. charset can be "ISO-2022-JP"
+			return cd.ConvString(str)
+		}
+
+	}
+	return str
+}
+
+func fromBase64(data string) string {
+	buf := bytes.NewBufferString(data)
+	decoder := base64.NewDecoder(base64.StdEncoding, buf)
+	res, _ := ioutil.ReadAll(decoder)
+	return string(res)
+}
+
+func fromQuotedP(data string) string {
+	buf := bytes.NewBufferString(data)
+	decoder := qprintable.NewDecoder(qprintable.BinaryEncoding, buf)
+	res, _ := ioutil.ReadAll(decoder)
+	return string(res)
+}
+
+var charsetRegex, _ = regexp.Compile(`[_:.\/\\]`)
+
+func fixCharset(charset string) string {
+	fixed_charset := charsetRegex.ReplaceAllString(charset, "-")
+	// Fix charset
+	// borrowed from http://squirrelmail.svn.sourceforge.net/viewvc/squirrelmail/trunk/squirrelmail/include/languages.php?revision=13765&view=markup
+	// OE ks_c_5601_1987 > cp949
+	fixed_charset = strings.Replace(fixed_charset, "ks-c-5601-1987", "cp949", -1)
+	// Moz x-euc-tw > euc-tw
+	fixed_charset = strings.Replace(fixed_charset, "x-euc", "euc", -1)
+	// Moz x-windows-949 > cp949
+	fixed_charset = strings.Replace(fixed_charset, "x-windows_", "cp", -1)
+	// windows-125x and cp125x charsets
+	fixed_charset = strings.Replace(fixed_charset, "windows-", "cp", -1)
+	// ibm > cp
+	fixed_charset = strings.Replace(fixed_charset, "ibm", "cp", -1)
+	// iso-8859-8-i -> iso-8859-8
+	fixed_charset = strings.Replace(fixed_charset, "iso-8859-8-i", "iso-8859-8", -1)
+	if charset != fixed_charset {
+		return fixed_charset
+	}
+	return charset
+}

+ 0 - 42
glide.lock

@@ -1,42 +0,0 @@
-hash: ce60d2a4a0a6b12d61788d65a3c64a31f0174e91ab796544260398dd0aa43114
-updated: 2017-01-24T11:26:52.507726508-08:00
-imports:
-- name: github.com/garyburd/redigo
-  version: 8873b2f1995f59d4bcdd2b0dc9858e2cb9bf0c13
-  subpackages:
-  - internal
-  - redis
-- name: github.com/gorilla/context
-  version: 08b5f424b9271eedf6f9f0ce86cb9396ed337a42
-- name: github.com/gorilla/mux
-  version: 392c28fe23e1c45ddba891b0320b3b5df220beea
-- name: github.com/gorilla/websocket
-  version: 0674c7c7968d9fac5f0f678325161ec31df406af
-- name: github.com/inconshreveable/mousetrap
-  version: 76626ae9c91c4f2a10f34cad8ce83ea42c93bb75
-- name: github.com/rakyll/statik
-  version: 274df120e9065bdd08eb1120e0375e3dc1ae8465
-  subpackages:
-  - fs
-- name: github.com/Sirupsen/logrus
-  version: d26492970760ca5d33129d2d799e34be5c4782eb
-- name: github.com/sloonz/go-qprintable
-  version: 775b3a4592d5bfc47b0ba398ec0d4dba018e5926
-- name: github.com/spf13/cobra
-  version: 0f056af21f5f368e5b0646079d0094a2c64150f7
-- name: github.com/spf13/pflag
-  version: a232f6d9f87afaaa08bafaff5da685f974b83313
-- name: github.com/ziutek/mymysql
-  version: e08c2f35356576b3c3690c252fe5dca728ae9292
-  subpackages:
-  - autorc
-  - godrv
-  - mysql
-  - native
-- name: golang.org/x/sys
-  version: 478fcf54317e52ab69f40bb4c7a1520288d7f7ea
-  subpackages:
-  - unix
-- name: gopkg.in/iconv.v1
-  version: 16a760eb7e186ae0e3aedda00d4a1daa4d0701d8
-testImports: []

+ 4 - 0
glide.yaml

@@ -19,3 +19,7 @@ import:
   version: ~0.1.0
   subpackages:
   - fs
+- package: github.com/asaskevich/EventBus
+  version: ab9e5ceb2cc1ca6f36a5813c928c534e837681c2
+- package: github.com/go-sql-driver/mysql
+  version: ^1.3.0

+ 8 - 4
goguerrilla.conf.sample

@@ -1,12 +1,14 @@
 {
+    "log_file" : "stderr",
+    "log_level" : "info",
     "allowed_hosts": [
       "guerrillamail.com",
-      "guerrillamailblock.com"
+      "guerrillamailblock.com",
       "sharklasers.com",
       "guerrillamail.net",
       "guerrillamail.org"
     ],
-    "primary_mail_host": "sharklasers.com",
+    "pid_file" : "/var/run/go-guerrilla.pid",
     "backend_name": "dummy",
     "backend_config": {
         "log_received_mails": true
@@ -22,7 +24,8 @@
             "listen_interface":"127.0.0.1:25",
             "start_tls_on":true,
             "tls_always_on":false,
-            "max_clients": 1000
+            "max_clients": 1000,
+            "log_file" : "stderr"
         },
         {
             "is_enabled" : true,
@@ -34,7 +37,8 @@
             "listen_interface":"127.0.0.1:465",
             "start_tls_on":false,
             "tls_always_on":true,
-            "max_clients":500
+            "max_clients":500,
+            "log_file" : "stderr"
         }
     ]
 }

+ 350 - 30
guerrilla.go

@@ -3,65 +3,349 @@ package guerrilla
 import (
 	"errors"
 	"sync"
+	"sync/atomic"
 
-	log "github.com/Sirupsen/logrus"
+	evbus "github.com/asaskevich/EventBus"
+	"github.com/flashmob/go-guerrilla/backends"
 	"github.com/flashmob/go-guerrilla/dashboard"
+	"github.com/flashmob/go-guerrilla/log"
 )
 
-func init() {
-	log.AddHook(dashboard.LogHook)
+const (
+	// server has just been created
+	GuerrillaStateNew = iota
+	// Server has been started and is running
+	GuerrillaStateStarted
+	// Server has just been stopped
+	GuerrillaStateStopped
+)
+
+type Errors []error
+
+// implement the Error interface
+func (e Errors) Error() string {
+	if len(e) == 1 {
+		return e[0].Error()
+	}
+	// multiple errors
+	msg := ""
+	for _, err := range e {
+		msg += "\n" + err.Error()
+	}
+	return msg
 }
 
 type Guerrilla interface {
-	Start() (startErrors []error)
+	Start() error
 	Shutdown()
+	Subscribe(topic string, fn interface{}) error
+	Publish(topic string, args ...interface{})
+	Unsubscribe(topic string, handler interface{}) error
+	SetLogger(log.Logger)
 }
 
 type guerrilla struct {
-	Config  *AppConfig
-	servers []server
-	backend *Backend
+	Config  AppConfig
+	servers map[string]*server
+	backend backends.Backend
+	// guard controls access to g.servers
+	guard   sync.Mutex
+	state   int8
+	bus     *evbus.EventBus
+	mainlog logStore
+}
+
+type logStore struct {
+	atomic.Value
+}
+
+// Get loads the log.logger in an atomic operation. Returns a stderr logger if not able to load
+func (ls *logStore) Get() log.Logger {
+	if v, ok := ls.Load().(log.Logger); ok {
+		return v
+	}
+	l, _ := log.GetLogger(log.OutputStderr.String())
+	return l
 }
 
 // Returns a new instance of Guerrilla with the given config, not yet running.
-func New(ac *AppConfig, b *Backend) Guerrilla {
-	g := &guerrilla{ac, []server{}, b}
-	// Instantiate servers
-	for _, sc := range ac.Servers {
-		if !sc.IsEnabled {
+func New(ac *AppConfig, b backends.Backend, l log.Logger) (Guerrilla, error) {
+	g := &guerrilla{
+		Config:  *ac, // take a local copy
+		servers: make(map[string]*server, len(ac.Servers)),
+		backend: b,
+		bus:     evbus.New(),
+	}
+	g.mainlog.Store(l)
+
+	if ac.LogLevel != "" {
+		g.mainlog.Get().SetLevel(ac.LogLevel)
+	}
+
+	g.state = GuerrillaStateNew
+	err := g.makeServers()
+
+	// subscribe for any events that may come in while running
+	g.subscribeEvents()
+	return g, err
+}
+
+// Instantiate servers
+func (g *guerrilla) makeServers() error {
+	g.mainlog.Get().Debug("making servers")
+	var errs Errors
+	for _, sc := range g.Config.Servers {
+		if _, ok := g.servers[sc.ListenInterface]; ok {
+			// server already instantiated
 			continue
 		}
-		// Add relevant app-wide config options to each server
-		sc.AllowedHosts = ac.AllowedHosts
-		server, err := newServer(sc, b)
+		server, err := newServer(&sc, g.backend, g.mainlog.Get())
 		if err != nil {
-			log.WithError(err).Error("Failed to create server")
-		} else {
-			g.servers = append(g.servers, *server)
+			g.mainlog.Get().WithError(err).Errorf("Failed to create server [%s]", sc.ListenInterface)
+			errs = append(errs, err)
+		}
+		if server != nil {
+			g.servers[sc.ListenInterface] = server
+			server.setAllowedHosts(g.Config.AllowedHosts)
+		}
+
+	}
+	if len(g.servers) == 0 {
+		errs = append(errs, errors.New("There are no servers that can start, please check your config"))
+	}
+	if len(errs) == 0 {
+		return nil
+	}
+	return errs
+}
+
+// find a server by interface, retuning the index of the config and instance of server
+func (g *guerrilla) findServer(iface string) (int, *server) {
+	g.guard.Lock()
+	defer g.guard.Unlock()
+	ret := -1
+	for i := range g.Config.Servers {
+		if g.Config.Servers[i].ListenInterface == iface {
+			server := g.servers[iface]
+			ret = i
+			return ret, server
 		}
 	}
-	return g
+	return ret, nil
+}
+
+func (g *guerrilla) removeServer(serverConfigIndex int, iface string) {
+	g.guard.Lock()
+	defer g.guard.Unlock()
+	delete(g.servers, iface)
+	// cut out from the slice
+	g.Config.Servers = append(g.Config.Servers[:serverConfigIndex], g.Config.Servers[1:]...)
+}
+
+func (g *guerrilla) addServer(sc *ServerConfig) {
+	g.guard.Lock()
+	defer g.guard.Unlock()
+	g.Config.Servers = append(g.Config.Servers, *sc)
+	g.makeServers()
+}
+
+func (g *guerrilla) setConfig(i int, sc *ServerConfig) {
+	g.guard.Lock()
+	defer g.guard.Unlock()
+	g.Config.Servers[i] = *sc
+	g.servers[sc.ListenInterface].setConfig(sc)
+}
+
+// mapServers calls a callback on each server in g.servers map
+// It locks the g.servers map before mapping
+func (g *guerrilla) mapServers(callback func(*server)) map[string]*server {
+	defer g.guard.Unlock()
+	g.guard.Lock()
+	for _, server := range g.servers {
+		callback(server)
+	}
+	return g.servers
+}
+
+// subscribeEvents subscribes event handlers for configuration change events
+func (g *guerrilla) subscribeEvents() {
+
+	// allowed_hosts changed, set for all servers
+	g.Subscribe("config_change:allowed_hosts", func(c *AppConfig) {
+		g.mapServers(func(server *server) {
+			server.setAllowedHosts(c.AllowedHosts)
+		})
+		g.mainlog.Get().Infof("allowed_hosts config changed, a new list was set")
+	})
+
+	// the main log file changed
+	g.Subscribe("config_change:log_file", func(c *AppConfig) {
+		var err error
+		var l log.Logger
+		if l, err = log.GetLogger(c.LogFile); err == nil {
+			g.mainlog.Store(l)
+			g.mapServers(func(server *server) {
+				server.mainlogStore.Store(l) // it will change to hl on the next accepted client
+			})
+			g.mainlog.Get().Infof("main log for new clients changed to to [%s]", c.LogFile)
+		} else {
+			g.mainlog.Get().WithError(err).Errorf("main logging change failed [%s]", c.LogFile)
+		}
+
+	})
+
+	// re-open the main log file (file not changed)
+	g.Subscribe("config_change:reopen_log_file", func(c *AppConfig) {
+		g.mainlog.Get().Reopen()
+		g.mainlog.Get().Infof("re-opened main log file [%s]", c.LogFile)
+	})
+
+	// when log level changes, apply to mainlog and server logs
+	g.Subscribe("config_change:log_level", func(c *AppConfig) {
+		g.mainlog.Get().SetLevel(c.LogLevel)
+		g.mapServers(func(server *server) {
+			server.log.SetLevel(c.LogLevel)
+		})
+		g.mainlog.Get().Infof("log level changed to [%s]", c.LogLevel)
+	})
+
+	// server config was updated
+	g.Subscribe("server_change:update_config", func(sc *ServerConfig) {
+		if i, _ := g.findServer(sc.ListenInterface); i != -1 {
+			g.setConfig(i, sc)
+		}
+	})
+
+	// add a new server to the config & start
+	g.Subscribe("server_change:new_server", func(sc *ServerConfig) {
+		if i, _ := g.findServer(sc.ListenInterface); i == -1 {
+			// not found, lets add it
+			g.addServer(sc)
+			g.mainlog.Get().Infof("New server added [%s]", sc.ListenInterface)
+			if g.state == GuerrillaStateStarted {
+				err := g.Start()
+				if err != nil {
+					g.mainlog.Get().WithError(err).Info("Event server_change:new_server returned errors when starting")
+				}
+			}
+		}
+	})
+	// start a server that already exists in the config and has been instantiated
+	g.Subscribe("server_change:start_server", func(sc *ServerConfig) {
+		if i, server := g.findServer(sc.ListenInterface); i != -1 {
+			if server.state == ServerStateStopped || server.state == ServerStateNew {
+				g.mainlog.Get().Infof("Starting server [%s]", server.listenInterface)
+				err := g.Start()
+				if err != nil {
+					g.mainlog.Get().WithError(err).Info("Event server_change:start_server returned errors when starting")
+				}
+			}
+		}
+	})
+	// stop running a server
+	g.Subscribe("server_change:stop_server", func(sc *ServerConfig) {
+		if i, server := g.findServer(sc.ListenInterface); i != -1 {
+			if server.state == ServerStateRunning {
+				server.Shutdown()
+				g.mainlog.Get().Infof("Server [%s] stopped.", sc.ListenInterface)
+			}
+		}
+	})
+	// server was removed from config
+	g.Subscribe("server_change:remove_server", func(sc *ServerConfig) {
+		if i, server := g.findServer(sc.ListenInterface); i != -1 {
+			server.Shutdown()
+			g.removeServer(i, sc.ListenInterface)
+			g.mainlog.Get().Infof("Server [%s] removed from config, stopped it.", sc.ListenInterface)
+		}
+	})
+
+	// TLS changes
+	g.Subscribe("server_change:tls_config", func(sc *ServerConfig) {
+		if i, server := g.findServer(sc.ListenInterface); i != -1 {
+			if err := server.configureSSL(); err == nil {
+				g.mainlog.Get().Infof("Server [%s] new TLS configuration loaded", sc.ListenInterface)
+			} else {
+				g.mainlog.Get().WithError(err).Errorf("Server [%s] failed to load the new TLS configuration", sc.ListenInterface)
+			}
+		}
+	})
+	// when server's timeout change.
+	g.Subscribe("server_change:timeout", func(sc *ServerConfig) {
+		g.mapServers(func(server *server) {
+			server.setTimeout(sc.Timeout)
+		})
+	})
+	// when server's max clients change.
+	g.Subscribe("server_change:max_clients", func(sc *ServerConfig) {
+		g.mapServers(func(server *server) {
+			// TODO resize the pool somehow
+		})
+	})
+	// when a server's log file changes
+	g.Subscribe("server_change:new_log_file", func(sc *ServerConfig) {
+		if i, server := g.findServer(sc.ListenInterface); i != -1 {
+			var err error
+			var l log.Logger
+			if l, err = log.GetLogger(sc.LogFile); err == nil {
+				g.mainlog.Store(l)
+				server.logStore.Store(l) // it will change to l on the next accepted client
+				g.mainlog.Get().Infof("Server [%s] changed, new clients will log to: [%s]",
+					sc.ListenInterface,
+					sc.LogFile,
+				)
+			} else {
+				g.mainlog.Get().WithError(err).Errorf(
+					"Server [%s] log change failed to: [%s]",
+					sc.ListenInterface,
+					sc.LogFile,
+				)
+			}
+		}
+	})
+	// when the daemon caught a sighup
+	g.Subscribe("server_change:reopen_log_file", func(sc *ServerConfig) {
+		if i, server := g.findServer(sc.ListenInterface); i != -1 {
+			server.log.Reopen()
+			g.mainlog.Get().Infof("Server [%s] re-opened log file [%s]", sc.ListenInterface, sc.LogFile)
+		}
+	})
+
 }
 
 // Entry point for the application. Starts all servers.
-func (g *guerrilla) Start() (startErrors []error) {
+func (g *guerrilla) Start() error {
+	var startErrors Errors
+	g.guard.Lock()
+	defer func() {
+		g.state = GuerrillaStateStarted
+		g.guard.Unlock()
+	}()
 	if len(g.servers) == 0 {
 		return append(startErrors, errors.New("No servers to start, please check the config"))
 	}
 	// channel for reading errors
 	errs := make(chan error, len(g.servers))
 	var startWG sync.WaitGroup
-	startWG.Add(len(g.servers))
+
 	// start servers, send any errors back to errs channel
-	for i := 0; i < len(g.servers); i++ {
+	for ListenInterface := range g.servers {
+		if !g.servers[ListenInterface].isEnabled() {
+			// not enabled
+			continue
+		}
+		if g.servers[ListenInterface].state != ServerStateNew &&
+			g.servers[ListenInterface].state != ServerStateStopped {
+			continue
+		}
+		startWG.Add(1)
 		go func(s *server) {
 			if err := s.Start(&startWG); err != nil {
 				errs <- err
-				startWG.Done()
 			}
-		}(&g.servers[i])
+		}(g.servers[ListenInterface])
 	}
-	// wait for all servers to start
+	// wait for all servers to start (or fail)
 	startWG.Wait()
 
 	if g.Config.Dashboard.Enabled {
@@ -77,13 +361,49 @@ func (g *guerrilla) Start() (startErrors []error) {
 			startErrors = append(startErrors, err)
 		}
 	}
-	return startErrors
+	if len(startErrors) > 0 {
+		return startErrors
+	} else {
+		if gw, ok := g.backend.(*backends.BackendGateway); ok {
+			if gw.State == backends.BackendStateShuttered {
+				_ = gw.Reinitialize()
+			}
+		}
+	}
+	return nil
 }
 
 func (g *guerrilla) Shutdown() {
-	for _, s := range g.servers {
-		s.Shutdown()
-		log.Infof("shutdown completed for [%s]", s.config.ListenInterface)
+	g.guard.Lock()
+	defer func() {
+		g.state = GuerrillaStateStopped
+		defer g.guard.Unlock()
+	}()
+	for ListenInterface, s := range g.servers {
+		if s.state == ServerStateRunning {
+			s.Shutdown()
+			g.mainlog.Get().Infof("shutdown completed for [%s]", ListenInterface)
+		}
+	}
+	if err := g.backend.Shutdown(); err != nil {
+		g.mainlog.Get().WithError(err).Warn("Backend failed to shutdown")
+	} else {
+		g.mainlog.Get().Infof("Backend shutdown completed")
 	}
-	log.Infof("Backend shutdown completed")
+}
+
+func (g *guerrilla) Subscribe(topic string, fn interface{}) error {
+	return g.bus.Subscribe(topic, fn)
+}
+
+func (g *guerrilla) Publish(topic string, args ...interface{}) {
+	g.bus.Publish(topic, args...)
+}
+
+func (g *guerrilla) Unsubscribe(topic string, handler interface{}) error {
+	return g.bus.Unsubscribe(topic, handler)
+}
+
+func (g *guerrilla) SetLogger(l log.Logger) {
+	g.mainlog.Store(l)
 }

+ 335 - 0
log/log.go

@@ -0,0 +1,335 @@
+package log
+
+import (
+	"bufio"
+	"io"
+	"io/ioutil"
+	"net"
+	"os"
+	"strings"
+	"sync"
+
+	log "github.com/Sirupsen/logrus"
+
+	"github.com/flashmob/go-guerrilla/dashboard"
+)
+
+type Logger interface {
+	log.FieldLogger
+	WithConn(conn net.Conn) *log.Entry
+	Reopen() error
+	GetLogDest() string
+	SetLevel(level string)
+	GetLevel() string
+	IsDebug() bool
+	AddHook(h log.Hook)
+}
+
+// Implements the Logger interface
+// It's a logrus logger wrapper that contains an instance of our LoggerHook
+type HookedLogger struct {
+
+	// satisfy the log.FieldLogger interface
+	*log.Logger
+
+	h LoggerHook
+}
+
+type loggerCache map[string]Logger
+
+// loggers store the cached loggers created by NewLogger
+var loggers struct {
+	cache loggerCache
+	// mutex guards the cache
+	sync.Mutex
+}
+
+// GetLogger returns a struct that implements Logger (i.e HookedLogger) with a custom hook.
+// It may be new or already created, (ie. singleton factory pattern)
+// The hook has been initialized with dest
+// dest can can be a path to a file, or the following string values:
+// "off" - disable any log output
+// "stdout" - write to standard output
+// "stderr" - write to standard error
+// If the file doesn't exists, a new file will be created. Otherwise it will be appended
+// Each Logger returned is cached on dest, subsequent call will get the cached logger if dest matches
+// If there was an error, the log will revert to stderr instead of using a custom hook
+
+func GetLogger(dest string) (Logger, error) {
+	loggers.Lock()
+	defer loggers.Unlock()
+	if loggers.cache == nil {
+		loggers.cache = make(loggerCache, 1)
+	} else {
+		if l, ok := loggers.cache[dest]; ok {
+			// return the one we found in the cache
+			return l, nil
+		}
+	}
+	logrus := log.New()
+	// we'll use the hook to output instead
+	logrus.Out = ioutil.Discard
+
+	l := &HookedLogger{}
+	l.Logger = logrus
+
+	// cache it
+	loggers.cache[dest] = l
+
+	// setup the hook
+	if h, err := NewLogrusHook(dest); err != nil {
+		// revert back to stderr
+		logrus.Out = os.Stderr
+		return l, err
+	} else {
+		logrus.Hooks.Add(h)
+		l.h = h
+	}
+
+	// add the dashboard hook
+	logrus.Hooks.Add(dashboard.LogHook)
+
+	return l, nil
+
+}
+
+// AddHook adds a new logrus hook
+func (l *HookedLogger) AddHook(h log.Hook) {
+	log.AddHook(h)
+}
+
+func (l *HookedLogger) IsDebug() bool {
+	return l.GetLevel() == log.DebugLevel.String()
+}
+
+// SetLevel sets a log level, one of the LogLevels
+func (l *HookedLogger) SetLevel(level string) {
+	var logLevel log.Level
+	var err error
+	if logLevel, err = log.ParseLevel(level); err != nil {
+		return
+	}
+	l.Level = logLevel
+	log.SetLevel(logLevel)
+}
+
+// GetLevel gets the current log level
+func (l *HookedLogger) GetLevel() string {
+	return l.Level.String()
+}
+
+// Reopen closes the log file and re-opens it
+func (l *HookedLogger) Reopen() error {
+	return l.h.Reopen()
+}
+
+// Fgetname Gets the file name
+func (l *HookedLogger) GetLogDest() string {
+	return l.h.GetLogDest()
+}
+
+// WithConn extends logrus to be able to log with a net.Conn
+func (l *HookedLogger) WithConn(conn net.Conn) *log.Entry {
+	var addr string = "unknown"
+
+	if conn != nil {
+		addr = conn.RemoteAddr().String()
+	}
+	return l.WithField("addr", addr)
+}
+
+// custom logrus hook
+
+// hookMu ensures all io operations are synced. Always on exported functions
+var hookMu sync.Mutex
+
+// LoggerHook extends the log.Hook interface by adding Reopen() and Rename()
+type LoggerHook interface {
+	log.Hook
+	Reopen() error
+	GetLogDest() string
+}
+type LogrusHook struct {
+	w io.Writer
+	// file descriptor, can be re-opened
+	fd *os.File
+	// filename to the file descriptor
+	fname string
+	// txtFormatter that doesn't use colors
+	plainTxtFormatter *log.TextFormatter
+
+	mu sync.Mutex
+}
+
+// newLogrusHook creates a new hook. dest can be a file name or one of the following strings:
+// "stderr" - log to stderr, lines will be written to os.Stdout
+// "stdout" - log to stdout, lines will be written to os.Stdout
+// "off" - no log, lines will be written to ioutil.Discard
+func NewLogrusHook(dest string) (LoggerHook, error) {
+	hookMu.Lock()
+	defer hookMu.Unlock()
+	hook := LogrusHook{fname: dest}
+	err := hook.setup(dest)
+	return &hook, err
+}
+
+type OutputOption int
+
+const (
+	OutputStderr OutputOption = 1 + iota
+	OutputStdout
+	OutputOff
+	OutputNull
+	OutputFile
+)
+
+var outputOptions = [...]string{
+	"stderr",
+	"stdout",
+	"off",
+	"",
+	"file",
+}
+
+func (o OutputOption) String() string {
+	return outputOptions[o-1]
+}
+
+func parseOutputOption(str string) OutputOption {
+	switch str {
+	case "stderr":
+		return OutputStderr
+	case "stdout":
+		return OutputStdout
+	case "off":
+		return OutputOff
+	case "":
+		return OutputNull
+	}
+	return OutputFile
+}
+
+// Setup sets the hook's writer w and file descriptor fd
+// assumes the hook.fd is closed and nil
+func (hook *LogrusHook) setup(dest string) error {
+
+	out := parseOutputOption(dest)
+	if out == OutputNull || out == OutputStderr {
+		hook.w = os.Stderr
+	} else if out == OutputStdout {
+		hook.w = os.Stdout
+	} else if out == OutputOff {
+		hook.w = ioutil.Discard
+	} else {
+		if _, err := os.Stat(dest); err == nil {
+			// file exists open the file for appending
+			if err := hook.openAppend(dest); err != nil {
+				return err
+			}
+		} else {
+			// create the file
+			if err := hook.openCreate(dest); err != nil {
+				return err
+			}
+		}
+	}
+	// disable colors when writing to file
+	if hook.fd != nil {
+		hook.plainTxtFormatter = &log.TextFormatter{DisableColors: true}
+	}
+	return nil
+}
+
+// openAppend opens the dest file for appending. Default to os.Stderr if it can't open dest
+func (hook *LogrusHook) openAppend(dest string) (err error) {
+	fd, err := os.OpenFile(dest, os.O_APPEND|os.O_WRONLY, 0644)
+	if err != nil {
+		log.WithError(err).Error("Could not open log file for appending")
+		hook.w = os.Stderr
+		hook.fd = nil
+		return
+	}
+	hook.w = bufio.NewWriter(fd)
+	hook.fd = fd
+	return
+}
+
+// openCreate creates a new dest file for appending. Default to os.Stderr if it can't open dest
+func (hook *LogrusHook) openCreate(dest string) (err error) {
+	fd, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
+	if err != nil {
+		log.WithError(err).Error("Could not create log file")
+		hook.w = os.Stderr
+		hook.fd = nil
+		return
+	}
+	hook.w = bufio.NewWriter(fd)
+	hook.fd = fd
+	return
+}
+
+// Fire implements the logrus Hook interface. It disables color text formatting if writing to a file
+func (hook *LogrusHook) Fire(entry *log.Entry) error {
+	hookMu.Lock()
+	defer hookMu.Unlock()
+	if hook.fd != nil {
+		// save the old hook
+		oldhook := entry.Logger.Formatter
+		defer func() {
+			// set the back to the old hook after we're done
+			entry.Logger.Formatter = oldhook
+		}()
+		// use the plain text hook
+		entry.Logger.Formatter = hook.plainTxtFormatter
+	}
+	if line, err := entry.String(); err == nil {
+		r := strings.NewReader(line)
+		if _, err = io.Copy(hook.w, r); err != nil {
+			return err
+		}
+		if wb, ok := hook.w.(*bufio.Writer); ok {
+			if err := wb.Flush(); err != nil {
+				return err
+			}
+			if hook.fd != nil {
+				hook.fd.Sync()
+			}
+		}
+		return err
+	} else {
+		return err
+	}
+}
+
+// GetLogDest returns the destination of the log as a string
+func (hook *LogrusHook) GetLogDest() string {
+	hookMu.Lock()
+	defer hookMu.Unlock()
+	return hook.fname
+}
+
+// Levels implements the logrus Hook interface
+func (hook *LogrusHook) Levels() []log.Level {
+	return log.AllLevels
+}
+
+// Reopen closes and re-open log file descriptor, which is a special feature of this hook
+func (hook *LogrusHook) Reopen() error {
+	hookMu.Lock()
+	defer hookMu.Unlock()
+	var err error
+	if hook.fd != nil {
+		if err = hook.fd.Close(); err != nil {
+			return err
+		}
+		// The file could have been re-named by an external program such as logrotate(8)
+		if _, err := os.Stat(hook.fname); err != nil {
+			// The file doesn't exist,create a new one.
+			return hook.openCreate(hook.fname)
+		} else {
+			return hook.openAppend(hook.fname)
+		}
+	}
+	return err
+
+}

+ 1 - 1
mocks/client.go

@@ -1,4 +1,4 @@
-package main
+package mocks
 
 import (
 	"fmt"

+ 101 - 0
mocks/conn_mock.go

@@ -0,0 +1,101 @@
+package mocks
+
+import (
+	"io"
+	"net"
+	"time"
+)
+
+// Mocks a net.Conn - server and client sides.
+// See server_test.go for usage examples
+// Taken from https://github.com/jordwest/mock-conn
+// This great answer http://stackoverflow.com/questions/1976950/simulate-a-tcp-connection-in-go
+
+// Addr is a fake network interface which implements the net.Addr interface
+type Addr struct {
+	NetworkString string
+	AddrString    string
+}
+
+func (a Addr) Network() string {
+	return a.NetworkString
+}
+
+func (a Addr) String() string {
+	return a.AddrString
+}
+
+// End is one 'end' of a simulated connection.
+type End struct {
+	Reader *io.PipeReader
+	Writer *io.PipeWriter
+}
+
+func (c End) Close() error {
+	if err := c.Writer.Close(); err != nil {
+		return err
+	}
+	if err := c.Reader.Close(); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (e End) Read(data []byte) (n int, err error)  { return e.Reader.Read(data) }
+func (e End) Write(data []byte) (n int, err error) { return e.Writer.Write(data) }
+
+func (e End) LocalAddr() net.Addr {
+	return Addr{
+		NetworkString: "tcp",
+		AddrString:    "127.0.0.1",
+	}
+}
+
+func (e End) RemoteAddr() net.Addr {
+	return Addr{
+		NetworkString: "tcp",
+		AddrString:    "127.0.0.1",
+	}
+}
+
+func (e End) SetDeadline(t time.Time) error      { return nil }
+func (e End) SetReadDeadline(t time.Time) error  { return nil }
+func (e End) SetWriteDeadline(t time.Time) error { return nil }
+
+// MockConn facilitates testing by providing two connected ReadWriteClosers
+// each of which can be used in place of a net.Conn
+type Conn struct {
+	Server *End
+	Client *End
+}
+
+func NewConn() *Conn {
+	// A connection consists of two pipes:
+	// Client      |      Server
+	//   writes   ===>  reads
+	//    reads  <===   writes
+
+	serverRead, clientWrite := io.Pipe()
+	clientRead, serverWrite := io.Pipe()
+
+	return &Conn{
+		Server: &End{
+			Reader: serverRead,
+			Writer: serverWrite,
+		},
+		Client: &End{
+			Reader: clientRead,
+			Writer: clientWrite,
+		},
+	}
+}
+
+func (c *Conn) Close() error {
+	if err := c.Server.Close(); err != nil {
+		return err
+	}
+	if err := c.Client.Close(); err != nil {
+		return err
+	}
+	return nil
+}

+ 0 - 60
models.go

@@ -3,10 +3,7 @@ package guerrilla
 import (
 	"bufio"
 	"errors"
-	"fmt"
 	"io"
-	"strconv"
-	"strings"
 )
 
 var (
@@ -14,63 +11,6 @@ var (
 	MessageSizeExceeded = errors.New("Maximum message size exceeded")
 )
 
-// Backends process received mail. Depending on the implementation, that can
-// be storing in a database, retransmitting to another server, etc.
-// Must return an SMTP message (i.e. "250 OK") and a boolean indicating
-// whether the message was processed successfully.
-type Backend interface {
-	Process(*Envelope) BackendResult
-	Shutdown() error
-}
-
-// BackendResult represents a response to an SMTP client after receiving DATA.
-// The String method should return an SMTP message ready to send back to the
-// client, for example `250 OK: Message received`.
-type BackendResult interface {
-	fmt.Stringer
-	// Code should return the SMTP code associated with this response, ie. `250`
-	Code() int
-}
-
-// Internal implementation of BackendResult for use by backend implementations.
-type backendResult string
-
-func (br backendResult) String() string {
-	return string(br)
-}
-
-// Parses the SMTP code from the first 3 characters of the SMTP message.
-// Returns 554 if code cannot be parsed.
-func (br backendResult) Code() int {
-	trimmed := strings.TrimSpace(string(br))
-	if len(trimmed) < 3 {
-		return 554
-	}
-	code, err := strconv.Atoi(trimmed[:3])
-	if err != nil {
-		return 554
-	}
-	return code
-}
-
-func NewBackendResult(message string) BackendResult {
-	return backendResult(message)
-}
-
-// EmailAddress encodes an email address of the form `<user@host>`
-type EmailAddress struct {
-	User string
-	Host string
-}
-
-func (ep *EmailAddress) String() string {
-	return fmt.Sprintf("%s@%s", ep.User, ep.Host)
-}
-
-func (ep *EmailAddress) isEmpty() bool {
-	return ep.User == "" && ep.Host == ""
-}
-
 // we need to adjust the limit, so we embed io.LimitedReader
 type adjustableLimitedReader struct {
 	R *io.LimitedReader

+ 33 - 20
pool.go

@@ -2,6 +2,7 @@ package guerrilla
 
 import (
 	"errors"
+	"github.com/flashmob/go-guerrilla/log"
 	"net"
 	"sync"
 	"sync/atomic"
@@ -41,6 +42,22 @@ type lentClients struct {
 	wg sync.WaitGroup
 }
 
+// maps the callback on all lentClients
+func (c *lentClients) mapAll(callback func(p Poolable)) {
+	defer c.mu.Unlock()
+	c.mu.Lock()
+	for _, item := range c.m {
+		callback(item)
+	}
+}
+
+// operation performs an operation on a Poolable item using the callback
+func (c *lentClients) operation(callback func(p Poolable), item Poolable) {
+	defer c.mu.Unlock()
+	c.mu.Lock()
+	callback(item)
+}
+
 // NewPool creates a new pool of Clients.
 func NewPool(poolSize int) *Pool {
 	return &Pool{
@@ -65,10 +82,9 @@ func (p *Pool) ShutdownState() {
 	p.ShutdownChan <- 1             // release any waiting p.sem
 
 	// set a low timeout
-	var c Poolable
-	for _, c = range p.activeClients.m {
-		c.setTimeout(time.Duration(int64(aVeryLowTimeout)))
-	}
+	p.activeClients.mapAll(func(p Poolable) {
+		p.setTimeout(time.Duration(int64(aVeryLowTimeout)))
+	})
 
 }
 
@@ -93,12 +109,9 @@ func (p *Pool) IsShuttingDown() bool {
 
 // set a timeout for all lent clients
 func (p *Pool) SetTimeout(duration time.Duration) {
-	var client Poolable
-	p.activeClients.mu.Lock()
-	defer p.activeClients.mu.Unlock()
-	for _, client = range p.activeClients.m {
-		client.setTimeout(duration)
-	}
+	p.activeClients.mapAll(func(p Poolable) {
+		p.setTimeout(duration)
+	})
 }
 
 // Gets the number of active clients that are currently
@@ -108,7 +121,7 @@ func (p *Pool) GetActiveClientsCount() int {
 }
 
 // Borrow a Client from the pool. Will block if len(activeClients) > maxClients
-func (p *Pool) Borrow(conn net.Conn, clientID uint64) (Poolable, error) {
+func (p *Pool) Borrow(conn net.Conn, clientID uint64, logger log.Logger) (Poolable, error) {
 	p.poolGuard.Lock()
 	defer p.poolGuard.Unlock()
 
@@ -123,7 +136,7 @@ func (p *Pool) Borrow(conn net.Conn, clientID uint64) (Poolable, error) {
 		case c = <-p.pool:
 			c.init(conn, clientID)
 		default:
-			c = NewClient(conn, clientID)
+			c = NewClient(conn, clientID, logger)
 		}
 		p.activeClientsAdd(c)
 
@@ -146,15 +159,15 @@ func (p *Pool) Return(c Poolable) {
 }
 
 func (p *Pool) activeClientsAdd(c Poolable) {
-	p.activeClients.mu.Lock()
-	p.activeClients.wg.Add(1)
-	p.activeClients.m[c.getID()] = c
-	p.activeClients.mu.Unlock()
+	p.activeClients.operation(func(item Poolable) {
+		p.activeClients.wg.Add(1)
+		p.activeClients.m[c.getID()] = item
+	}, c)
 }
 
 func (p *Pool) activeClientsRemove(c Poolable) {
-	p.activeClients.mu.Lock()
-	p.activeClients.wg.Done()
-	delete(p.activeClients.m, c.getID())
-	p.activeClients.mu.Unlock()
+	p.activeClients.operation(func(item Poolable) {
+		delete(p.activeClients.m, item.getID())
+		p.activeClients.wg.Done()
+	}, c)
 }

+ 454 - 0
response/enhanced.go

@@ -0,0 +1,454 @@
+package response
+
+import (
+	"fmt"
+)
+
+const (
+	// ClassSuccess specifies that the DSN is reporting a positive delivery
+	// action.  Detail sub-codes may provide notification of
+	// transformations required for delivery.
+	ClassSuccess = 2
+	// ClassTransientFailure - a persistent transient failure is one in which the message as
+	// sent is valid, but persistence of some temporary condition has
+	// caused abandonment or delay of attempts to send the message.
+	// If this code accompanies a delivery failure report, sending in
+	// the future may be successful.
+	ClassTransientFailure = 4
+	// ClassPermanentFailure - a permanent failure is one which is not likely to be resolved
+	// by resending the message in the current form.  Some change to
+	// the message or the destination must be made for successful
+	// delivery.
+	ClassPermanentFailure = 5
+)
+
+// class is a type for ClassSuccess, ClassTransientFailure and ClassPermanentFailure constants
+type class int
+
+// String implements stringer for the class type
+func (c class) String() string {
+	return fmt.Sprintf("%c00", c)
+}
+
+// codeMap for mapping Enhanced Status Code to Basic Code
+// Mapping according to https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xml
+// This might not be entirely useful
+var codeMap = struct {
+	m map[EnhancedStatusCode]int
+}{m: map[EnhancedStatusCode]int{
+
+	EnhancedStatusCode{ClassSuccess, OtherAddressStatus}:               250,
+	EnhancedStatusCode{ClassSuccess, DestinationMailboxAddressValid}:   250,
+	EnhancedStatusCode{ClassSuccess, OtherOrUndefinedMailSystemStatus}: 250,
+	EnhancedStatusCode{ClassSuccess, OtherOrUndefinedProtocolStatus}:   250,
+	EnhancedStatusCode{ClassSuccess, ConversionWithLossPerformed}:      250,
+	EnhancedStatusCode{ClassSuccess, ".6.8"}:                           252,
+	EnhancedStatusCode{ClassSuccess, ".7.0"}:                           220,
+
+	EnhancedStatusCode{ClassTransientFailure, BadDestinationMailboxAddress}:      451,
+	EnhancedStatusCode{ClassTransientFailure, BadSendersSystemAddress}:           451,
+	EnhancedStatusCode{ClassTransientFailure, MailingListExpansionProblem}:       450,
+	EnhancedStatusCode{ClassTransientFailure, OtherOrUndefinedMailSystemStatus}:  421,
+	EnhancedStatusCode{ClassTransientFailure, MailSystemFull}:                    452,
+	EnhancedStatusCode{ClassTransientFailure, SystemNotAcceptingNetworkMessages}: 453,
+	EnhancedStatusCode{ClassTransientFailure, NoAnswerFromHost}:                  451,
+	EnhancedStatusCode{ClassTransientFailure, BadConnection}:                     421,
+	EnhancedStatusCode{ClassTransientFailure, RoutingServerFailure}:              451,
+	EnhancedStatusCode{ClassTransientFailure, NetworkCongestion}:                 451,
+	EnhancedStatusCode{ClassTransientFailure, OtherOrUndefinedProtocolStatus}:    451,
+	EnhancedStatusCode{ClassTransientFailure, InvalidCommand}:                    430,
+	EnhancedStatusCode{ClassTransientFailure, TooManyRecipients}:                 452,
+	EnhancedStatusCode{ClassTransientFailure, InvalidCommandArguments}:           451,
+	EnhancedStatusCode{ClassTransientFailure, ".7.0"}:                            450,
+	EnhancedStatusCode{ClassTransientFailure, ".7.1"}:                            451,
+	EnhancedStatusCode{ClassTransientFailure, ".7.12"}:                           422,
+	EnhancedStatusCode{ClassTransientFailure, ".7.15"}:                           450,
+	EnhancedStatusCode{ClassTransientFailure, ".7.24"}:                           451,
+
+	EnhancedStatusCode{ClassPermanentFailure, BadDestinationMailboxAddress}:            550,
+	EnhancedStatusCode{ClassPermanentFailure, BadDestinationMailboxAddressSyntax}:      501,
+	EnhancedStatusCode{ClassPermanentFailure, BadSendersSystemAddress}:                 501,
+	EnhancedStatusCode{ClassPermanentFailure, ".1.10"}:                                 556,
+	EnhancedStatusCode{ClassPermanentFailure, MailboxFull}:                             552,
+	EnhancedStatusCode{ClassPermanentFailure, MessageLengthExceedsAdministrativeLimit}: 552,
+	EnhancedStatusCode{ClassPermanentFailure, OtherOrUndefinedMailSystemStatus}:        550,
+	EnhancedStatusCode{ClassPermanentFailure, MessageTooBigForSystem}:                  552,
+	EnhancedStatusCode{ClassPermanentFailure, RoutingServerFailure}:                    550,
+	EnhancedStatusCode{ClassPermanentFailure, OtherOrUndefinedProtocolStatus}:          501,
+	EnhancedStatusCode{ClassPermanentFailure, InvalidCommand}:                          500,
+	EnhancedStatusCode{ClassPermanentFailure, SyntaxError}:                             500,
+	EnhancedStatusCode{ClassPermanentFailure, InvalidCommandArguments}:                 501,
+	EnhancedStatusCode{ClassPermanentFailure, ".5.6"}:                                  500,
+	EnhancedStatusCode{ClassPermanentFailure, ConversionRequiredButNotSupported}:       554,
+	EnhancedStatusCode{ClassPermanentFailure, ".6.6"}:                                  554,
+	EnhancedStatusCode{ClassPermanentFailure, ".6.7"}:                                  553,
+	EnhancedStatusCode{ClassPermanentFailure, ".6.8"}:                                  550,
+	EnhancedStatusCode{ClassPermanentFailure, ".6.9"}:                                  550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.0"}:                                  550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.1"}:                                  551,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.2"}:                                  550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.4"}:                                  504,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.8"}:                                  554,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.9"}:                                  534,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.10"}:                                 523,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.11"}:                                 524,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.13"}:                                 525,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.14"}:                                 535,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.15"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.16"}:                                 552,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.17"}:                                 500,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.18"}:                                 500,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.19"}:                                 500,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.20"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.21"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.22"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.23"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.24"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.25"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.26"}:                                 550,
+	EnhancedStatusCode{ClassPermanentFailure, ".7.27"}:                                 550,
+}}
+
+var (
+	// Canned is to be read-only, except in the init() function
+	Canned Responses
+)
+
+// Responses has some already pre-constructed responses
+type Responses struct {
+
+	// The 500's
+	FailLineTooLong              string
+	FailNestedMailCmd            string
+	FailNoSenderDataCmd          string
+	FailNoRecipientsDataCmd      string
+	FailUnrecognizedCmd          string
+	FailMaxUnrecognizedCmd       string
+	FailReadLimitExceededDataCmd string
+	FailMessageSizeExceeded      string
+	FailReadErrorDataCmd         string
+	FailPathTooLong              string
+	FailInvalidAddress           string
+	FailLocalPartTooLong         string
+	FailDomainTooLong            string
+	FailBackendNotRunning        string
+	FailBackendTransaction       string
+	FailBackendTimeout           string
+
+	// The 400's
+	ErrorTooManyRecipients string
+	ErrorRelayDenied       string
+	ErrorShutdown          string
+
+	// The 200's
+	SuccessMailCmd       string
+	SuccessRcptCmd       string
+	SuccessResetCmd      string
+	SuccessVerifyCmd     string
+	SuccessNoopCmd       string
+	SuccessQuitCmd       string
+	SuccessDataCmd       string
+	SuccessStartTLSCmd   string
+	SuccessMessageQueued string
+}
+
+// Called automatically during package load to build up the Responses struct
+func init() {
+
+	// There's even a Wikipedia page for canned responses: https://en.wikipedia.org/wiki/Canned_response
+	Canned = Responses{}
+
+	Canned.FailLineTooLong = (&Response{
+		EnhancedCode: InvalidCommand,
+		BasicCode:    554,
+		Class:        ClassPermanentFailure,
+		Comment:      "Line too long.",
+	}).String()
+
+	Canned.FailNestedMailCmd = (&Response{
+		EnhancedCode: InvalidCommand,
+		BasicCode:    503,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: nested MAIL command",
+	}).String()
+
+	Canned.SuccessMailCmd = (&Response{
+		EnhancedCode: OtherAddressStatus,
+		Class:        ClassSuccess,
+	}).String()
+
+	Canned.SuccessRcptCmd = (&Response{
+		EnhancedCode: DestinationMailboxAddressValid,
+		Class:        ClassSuccess,
+	}).String()
+
+	Canned.SuccessResetCmd = Canned.SuccessMailCmd
+	Canned.SuccessNoopCmd = (&Response{
+		EnhancedCode: OtherStatus,
+		Class:        ClassSuccess,
+	}).String()
+
+	Canned.SuccessVerifyCmd = (&Response{
+		EnhancedCode: OtherOrUndefinedProtocolStatus,
+		BasicCode:    252,
+		Class:        ClassSuccess,
+		Comment:      "Cannot verify user",
+	}).String()
+
+	Canned.ErrorTooManyRecipients = (&Response{
+		EnhancedCode: TooManyRecipients,
+		BasicCode:    452,
+		Class:        ClassTransientFailure,
+		Comment:      "Too many recipients",
+	}).String()
+
+	Canned.ErrorRelayDenied = (&Response{
+		EnhancedCode: BadDestinationMailboxAddress,
+		BasicCode:    454,
+		Class:        ClassTransientFailure,
+		Comment:      "Error: Relay access denied: ",
+	}).String()
+
+	Canned.SuccessQuitCmd = (&Response{
+		EnhancedCode: OtherStatus,
+		BasicCode:    221,
+		Class:        ClassSuccess,
+		Comment:      "Bye",
+	}).String()
+
+	Canned.FailNoSenderDataCmd = (&Response{
+		EnhancedCode: InvalidCommand,
+		BasicCode:    503,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: No sender",
+	}).String()
+
+	Canned.FailNoRecipientsDataCmd = (&Response{
+		EnhancedCode: InvalidCommand,
+		BasicCode:    503,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: No recipients",
+	}).String()
+
+	Canned.SuccessDataCmd = "354 Enter message, ending with '.' on a line by itself"
+
+	Canned.SuccessStartTLSCmd = (&Response{
+		EnhancedCode: OtherStatus,
+		BasicCode:    220,
+		Class:        ClassSuccess,
+		Comment:      "Ready to start TLS",
+	}).String()
+
+	Canned.FailUnrecognizedCmd = (&Response{
+		EnhancedCode: InvalidCommand,
+		BasicCode:    554,
+		Class:        ClassPermanentFailure,
+		Comment:      "Unrecognized command",
+	}).String()
+
+	Canned.FailMaxUnrecognizedCmd = (&Response{
+		EnhancedCode: InvalidCommand,
+		BasicCode:    554,
+		Class:        ClassPermanentFailure,
+		Comment:      "Too many unrecognized commands",
+	}).String()
+
+	Canned.ErrorShutdown = (&Response{
+		EnhancedCode: OtherOrUndefinedMailSystemStatus,
+		BasicCode:    421,
+		Class:        ClassTransientFailure,
+		Comment:      "Server is shutting down. Please try again later. Sayonara!",
+	}).String()
+
+	Canned.FailReadLimitExceededDataCmd = (&Response{
+		EnhancedCode: SyntaxError,
+		BasicCode:    550,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: ",
+	}).String()
+
+	Canned.FailMessageSizeExceeded = (&Response{
+		EnhancedCode: SyntaxError,
+		BasicCode:    550,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: ",
+	}).String()
+
+	Canned.FailReadErrorDataCmd = (&Response{
+		EnhancedCode: OtherOrUndefinedMailSystemStatus,
+		BasicCode:    451,
+		Class:        ClassTransientFailure,
+		Comment:      "Error: ",
+	}).String()
+
+	Canned.FailPathTooLong = (&Response{
+		EnhancedCode: InvalidCommandArguments,
+		BasicCode:    550,
+		Class:        ClassPermanentFailure,
+		Comment:      "Path too long",
+	}).String()
+
+	Canned.FailInvalidAddress = (&Response{
+		EnhancedCode: InvalidCommandArguments,
+		BasicCode:    501,
+		Class:        ClassPermanentFailure,
+		Comment:      "Invalid address",
+	}).String()
+
+	Canned.FailLocalPartTooLong = (&Response{
+		EnhancedCode: InvalidCommandArguments,
+		BasicCode:    550,
+		Class:        ClassPermanentFailure,
+		Comment:      "Local part too long, cannot exceed 64 characters",
+	}).String()
+
+	Canned.FailDomainTooLong = (&Response{
+		EnhancedCode: InvalidCommandArguments,
+		BasicCode:    550,
+		Class:        ClassPermanentFailure,
+		Comment:      "Domain cannot exceed 255 characters",
+	}).String()
+
+	Canned.FailBackendNotRunning = (&Response{
+		EnhancedCode: OtherOrUndefinedProtocolStatus,
+		BasicCode:    554,
+		Class:        ClassPermanentFailure,
+		Comment:      "Transaction failed - backend not running ",
+	}).String()
+
+	Canned.FailBackendTransaction = (&Response{
+		EnhancedCode: OtherOrUndefinedProtocolStatus,
+		BasicCode:    554,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: ",
+	}).String()
+
+	Canned.SuccessMessageQueued = (&Response{
+		EnhancedCode: OtherStatus,
+		BasicCode:    250,
+		Class:        ClassSuccess,
+		Comment:      "OK : queued as ",
+	}).String()
+
+	Canned.FailBackendTimeout = (&Response{
+		EnhancedCode: OtherOrUndefinedProtocolStatus,
+		BasicCode:    554,
+		Class:        ClassPermanentFailure,
+		Comment:      "Error: transaction timeout",
+	}).String()
+
+}
+
+// DefaultMap contains defined default codes (RfC 3463)
+const (
+	OtherStatus                             = ".0.0"
+	OtherAddressStatus                      = ".1.0"
+	BadDestinationMailboxAddress            = ".1.1"
+	BadDestinationSystemAddress             = ".1.2"
+	BadDestinationMailboxAddressSyntax      = ".1.3"
+	DestinationMailboxAddressAmbiguous      = ".1.4"
+	DestinationMailboxAddressValid          = ".1.5"
+	MailboxHasMoved                         = ".1.6"
+	BadSendersMailboxAddressSyntax          = ".1.7"
+	BadSendersSystemAddress                 = ".1.8"
+	OtherOrUndefinedMailboxStatus           = ".2.0"
+	MailboxDisabled                         = ".2.1"
+	MailboxFull                             = ".2.2"
+	MessageLengthExceedsAdministrativeLimit = ".2.3"
+	MailingListExpansionProblem             = ".2.4"
+	OtherOrUndefinedMailSystemStatus        = ".3.0"
+	MailSystemFull                          = ".3.1"
+	SystemNotAcceptingNetworkMessages       = ".3.2"
+	SystemNotCapableOfSelectedFeatures      = ".3.3"
+	MessageTooBigForSystem                  = ".3.4"
+	OtherOrUndefinedNetworkOrRoutingStatus  = ".4.0"
+	NoAnswerFromHost                        = ".4.1"
+	BadConnection                           = ".4.2"
+	RoutingServerFailure                    = ".4.3"
+	UnableToRoute                           = ".4.4"
+	NetworkCongestion                       = ".4.5"
+	RoutingLoopDetected                     = ".4.6"
+	DeliveryTimeExpired                     = ".4.7"
+	OtherOrUndefinedProtocolStatus          = ".5.0"
+	InvalidCommand                          = ".5.1"
+	SyntaxError                             = ".5.2"
+	TooManyRecipients                       = ".5.3"
+	InvalidCommandArguments                 = ".5.4"
+	WrongProtocolVersion                    = ".5.5"
+	OtherOrUndefinedMediaError              = ".6.0"
+	MediaNotSupported                       = ".6.1"
+	ConversionRequiredAndProhibited         = ".6.2"
+	ConversionRequiredButNotSupported       = ".6.3"
+	ConversionWithLossPerformed             = ".6.4"
+	ConversionFailed                        = ".6.5"
+)
+
+var defaultTexts = struct {
+	m map[EnhancedStatusCode]string
+}{m: map[EnhancedStatusCode]string{
+	EnhancedStatusCode{ClassSuccess, ".0.0"}:          "OK",
+	EnhancedStatusCode{ClassSuccess, ".1.0"}:          "OK",
+	EnhancedStatusCode{ClassSuccess, ".1.5"}:          "OK",
+	EnhancedStatusCode{ClassSuccess, ".5.0"}:          "OK",
+	EnhancedStatusCode{ClassTransientFailure, ".5.3"}: "Too many recipients",
+	EnhancedStatusCode{ClassTransientFailure, ".5.4"}: "Relay access denied",
+	EnhancedStatusCode{ClassPermanentFailure, ".5.1"}: "Invalid command",
+}}
+
+// Response type for Stringer interface
+type Response struct {
+	EnhancedCode subjectDetail
+	BasicCode    int
+	Class        class
+	// Comment is optional
+	Comment string
+}
+
+// it looks like this ".5.4"
+type subjectDetail string
+
+// EnhancedStatus are the ones that look like 2.1.0
+type EnhancedStatusCode struct {
+	Class             class
+	SubjectDetailCode subjectDetail
+}
+
+// String returns a string representation of EnhancedStatus
+func (e EnhancedStatusCode) String() string {
+	return fmt.Sprintf("%d%s", e.Class, e.SubjectDetailCode)
+}
+
+// String returns a custom Response as a string
+func (r *Response) String() string {
+
+	basicCode := r.BasicCode
+	comment := r.Comment
+	if len(comment) == 0 && r.BasicCode == 0 {
+		var ok bool
+		if comment, ok = defaultTexts.m[EnhancedStatusCode{r.Class, r.EnhancedCode}]; !ok {
+			switch r.Class {
+			case 2:
+				comment = "OK"
+			case 4:
+				comment = "Temporary failure."
+			case 5:
+				comment = "Permanent failure."
+			}
+		}
+	}
+	e := EnhancedStatusCode{r.Class, r.EnhancedCode}
+	if r.BasicCode == 0 {
+		basicCode = getBasicStatusCode(e)
+	}
+
+	return fmt.Sprintf("%d %s %s", basicCode, e.String(), comment)
+}
+
+// getBasicStatusCode gets the basic status code from codeMap, or fallback code if not mapped
+func getBasicStatusCode(e EnhancedStatusCode) int {
+	if val, ok := codeMap.m[e]; ok {
+		return val
+	}
+	// Fallback if code is not defined
+	return int(e.Class) * 100
+}

+ 63 - 0
response/enhanced_test.go

@@ -0,0 +1,63 @@
+package response
+
+import (
+	"testing"
+)
+
+func TestClass(t *testing.T) {
+	if ClassPermanentFailure != 5 {
+		t.Error("ClassPermanentFailure is not 5")
+	}
+	if ClassTransientFailure != 4 {
+		t.Error("ClassTransientFailure is not 4")
+	}
+	if ClassSuccess != 2 {
+		t.Error("ClassSuccess is not 2")
+	}
+}
+
+func TestGetBasicStatusCode(t *testing.T) {
+	// Known status code
+	a := getBasicStatusCode(EnhancedStatusCode{2, OtherOrUndefinedProtocolStatus})
+	if a != 250 {
+		t.Errorf("getBasicStatusCode. Int \"%d\" not expected.", a)
+	}
+
+	// Unknown status code
+	b := getBasicStatusCode(EnhancedStatusCode{2, OtherStatus})
+	if b != 200 {
+		t.Errorf("getBasicStatusCode. Int \"%d\" not expected.", b)
+	}
+}
+
+// TestString for the String function
+func TestCustomString(t *testing.T) {
+	// Basic testing
+	resp := &Response{
+		EnhancedCode: OtherStatus,
+		BasicCode:    200,
+		Class:        ClassSuccess,
+		Comment:      "Test",
+	}
+
+	if resp.String() != "200 2.0.0 Test" {
+		t.Errorf("CustomString failed. String \"%s\" not expected.", resp)
+	}
+
+	// Default String
+	resp2 := &Response{
+		EnhancedCode: OtherStatus,
+		Class:        ClassSuccess,
+	}
+	if resp2.String() != "200 2.0.0 OK" {
+		t.Errorf("String failed. String \"%s\" not expected.", resp2)
+	}
+}
+
+func TestBuildEnhancedResponseFromDefaultStatus(t *testing.T) {
+	//a := buildEnhancedResponseFromDefaultStatus(ClassPermanentFailure, InvalidCommand)
+	a := EnhancedStatusCode{ClassPermanentFailure, InvalidCommand}.String()
+	if a != "5.5.1" {
+		t.Errorf("buildEnhancedResponseFromDefaultStatus failed. String \"%s\" not expected.", a)
+	}
+}

+ 160 - 0
response/quote.go

@@ -0,0 +1,160 @@
+package response
+
+import (
+	"fmt"
+	"math/rand"
+	"time"
+)
+
+// This is an easter egg
+
+const CRLF = "\r\n"
+
+var quotes = struct {
+	m map[int]string
+}{m: map[int]string{
+	0: "214 Maude Lebowski: He's a good man....and thorough.",
+	1: "214 The Dude: I had a rough night and I hate the f***ing Eagles, man.",
+	2: "214 Walter Sobchak: The chinaman is not the issue here... also dude, Asian American please",
+	3: "214 The Dude: Walter, the chinamen who peed on my rug I can't give him a bill, so what the f**k are you talking about?",
+	4: "214 The Dude: Hey, I know that guy, he's a nihilist. Karl Hungus.",
+	5: "214-Malibu Police Chief: Mr. Treehorn tells us that he had to eject you from his garden party; that you were drunk and abusive." + CRLF +
+		"214 The Dude: Mr. Treehorn treats objects like women, man.",
+	6: "214 Walter Sobchak: Shut the f**k up, Donny!",
+	7: "214-Donny: Shut the f**k up, Donny!" + CRLF +
+		"214 Walter Sobchak: Shut the f**k up, Donny!",
+	8:  "214 The Dude: It really tied the room together.",
+	9:  "214 Walter Sobchak: Is this your homework, Larry?",
+	10: "214 The Dude: Who the f**k are the Knutsens?",
+	11: "214 The Dude: Yeah,well, that's just, like, your opinion, man.",
+	12: "214-Walter Sobchak: Am I the only one who gives a s**t about the rules?!" + CRLF +
+		"214 Walter Sobchak: Am I the only one who gives a s**t about the rules?",
+	13: "214-Walter Sobchak: Am I wrong?" + CRLF +
+		"214-The Dude: No, you're not wrong Walter, you're just an ass-hole." +
+		"214 Walter Sobchak: Okay then.",
+	14: "214-Private Snoop: you see what happens lebowski?" + CRLF +
+		"214-The Dude: nobody calls me lebowski, you got the wrong guy, I'm the the dude, man." + CRLF +
+		"214-Private Snoop: Your name's Lebowski, Lebowski. Your wife is Bunny." + CRLF +
+		"214-The Dude: My wife? Bunny? Do you see a wedding ring on my finger? " + CRLF +
+		"214 Does this place look like I'm f**kin married? The toilet seat's up man!",
+	15: "214-The Dude: Yeah man. it really tied the room together." + CRLF +
+		"214-Donny: What tied the room together dude?" + CRLF +
+		"214-The Dude: My rug." + CRLF +
+		"214-Walter Sobchak: Were you listening to the Dude's story, Donny?" + CRLF +
+		"214-Donny: I was bowling." + CRLF +
+		"214-Walter Sobchak: So then you have no frame of reference here, Donny, " + CRLF +
+		"214 You're like a child who wonders in the middle of movie.",
+	16: "214-The Dude: She probably kidnapped herself." + CRLF +
+		"214-Donny: What do you mean dude?" + CRLF +
+		"214-The Dude: Rug Peers did not do this. look at it. " + CRLF +
+		"214-A young trophy wife, marries this guy for his money, she figures he " + CRLF +
+		"214-hasn't given her enough, she owes money all over town." + CRLF +
+		"214 Walter Sobchak: That f**kin bitch.",
+	17: "214 Walter Sobchak: Forget it, Donny, you're out of your element!",
+	18: "214-Walter Sobchak: You want a toe? I can get you a toe, believe me." + CRLF +
+		"214-There are ways, Dude. You don't wanna know about it, believe me. " + CRLF +
+		"214-The Dude: Yeah, but Walter." +
+		"214 Walter Sobchak: Hell, I can get you a toe by 3 o'clock this afternoon with nail polish.",
+	19: "214 Walter Sobchak: Calmer then you are.",
+	20: "214 Walter Sobchak: You are entering a world of pain",
+	21: "214 The Dude: This aggression will not stand man.",
+	22: "214 The Dude: His dudeness, duder, or el dudorino",
+	23: "214 Walter Sobchak: Has the whole world gone crazy!",
+	24: "214 Walter Sobchak: Calm down your being very undude.",
+	25: "214-Donny: Are these the Nazis, Walter?" + CRLF +
+		"214 Walter Sobchak: No Donny, these men are nihilists. There's nothing to be afraid of.",
+	26: "214 Walter Sobchak: Well, it was parked in the handicapped zone. Perhaps they towed it.",
+	27: "214-Private Snoop: I'm a brother shamus!" + CRLF +
+		"214 The Dude: Brother Seamus? Like an Irish monk?",
+	28: "214 Walter Sobchak: Have you ever of Vietnam? You're about to enter a world of pain!",
+	29: "214-Donny: What's a pederast, Walter?" + CRLF +
+		"214 Walter Sobchak: Shut the f**k up, Donny.",
+	30: "214 The Dude: Hey, careful, man, there's a beverage here!",
+	31: "214 The Stranger: Sometimes you eat the bar and sometimes, well, the bar eats you.",
+	32: "214 Walter Sobchak: Goodnight, sweet prince.",
+	33: "214 Jackie Treehorn: People forget the brain is the biggest erogenous zone.",
+	34: "214-The Big Lebowski: What makes a man? Is it doing the right thing?" + CRLF +
+		"214 The Dude: Sure, that and a pair of testicles.",
+	35: "214 The Dude: At least I'm housebroken.",
+	36: "214-Walter Sobchak: Three thousand years of beautiful tradition, from Moses to Sandy Koufax." + CRLF +
+		"214 You're goddamn right I'm living in the f**king past!",
+	37: "214-The Stranger: There's just one thing, dude." + CRLF +
+		"214-The Dude: What's that?" + CRLF +
+		"214-The Stranger: Do you have to use so many cuss words?" + CRLF +
+		"214 The Dude: What the f**k you talkin' about?",
+	38: "214-Walter Sobchak: I mean, say what you want about the tenets of National Socialism, " + CRLF +
+		"214 Dude, at least it's an ethos.",
+	39: "214 The Dude: My only hope is that the Big Lebowski kills me before the Germans can cut my d**k off.",
+	40: "214 The Dude: You human paraquat!",
+	41: "214 The Dude: Strikes and gutters, ups and downs.",
+	42: "214 The Dude: Sooner or later you are going to have to face the fact that your a moron.",
+	43: "214-The Dude: The fixes the cable?" + CRLF +
+		"214 Maude Lebowski: Don't be fatuous Jerry.",
+	44: "214 The Dude: Yeah, well, that's just, like, your opinion, man.",
+	45: "214 The Dude: I don't need your sympathy, I need my Johnson.",
+	46: "214 Donny: I am the walrus.",
+	47: "214 The Dude: We f**ked it up!",
+	48: "214 Jesus Quintana: You got that right, NO ONE f**ks with the jesus.",
+	49: "214 Walter Sobchak: You can say what you want about the tenets of national socialism but at least it's an ethos.",
+	50: "214-Walter Sobchak: f**king Germans. Nothing changes. f**king Nazis." + CRLF +
+		"214-Donny: They were Nazis, Dude?" + CRLF +
+		"214 Walter Sobchak: Oh, come on Donny, they were threatening castration! Are we gonna split hairs here? Am I wrong?",
+	51: "214 Walter Sobchak: [pulls out a gun] Smokey, my friend, you are entering a world of pain.",
+	52: "214 Walter Sobchak: This is what happens when you f**k a stranger in the ass!",
+	53: "214-The Dude: We dropped off the money." + CRLF +
+		"214-The Big Lebowski: *We*!?" + CRLF +
+		"214 The Dude: *I*; the royal we.",
+	54: "214 Walter Sobchak: You see what happens larry when you f**k a stranger in the ass.",
+	55: "214 The Dude: The Dude abides.",
+	56: "214 Walter Sobchak: f**k it dude, lets go bowling.",
+	57: "214 The Dude: I can't be worrying about that s**t. Life goes on, man.",
+	58: "214 Walter Sobchak: The ringer cannot look empty.",
+	59: "214-Malibu Police Chief: I don't like your jerk-off name, I don't like your jerk-off face," + CRLF +
+		"214 I don't like your j3rk-off behavior, and I don't like you... j3rk-off.",
+	60: "214-Walter Sobchak: Has the whole world gone CRAZY? Am I the only one around here who gives" + CRLF +
+		"214 a s**t about the rules? You think I'm f**kin' around, MARK IT ZERO!",
+	61: "214 Walter Sobchak: Look, Larry. Have you ever heard of Vietnam?",
+	62: "214 The Dude: Ha hey, this is a private residence man.",
+	63: "214 The Dude: Obviously you're not a golfer.",
+	64: "214 Walter Sobchak: You know, Dude, I myself dabbled in pacifism once. Not in Nam, of course.",
+	65: "214 Walter Sobchak: Donny, you're out of your element!",
+	66: "214 The Dude: Another caucasian, Gary.",
+	67: "214-Bunny Lebowski: I'll s**k your c**k for a thousand dollars." + CRLF +
+		"214-Brandt: Ah... Ha... ha... HA! Yes, we're all very fond of her." + CRLF +
+		"214-Bunny Lebowski: Brandt can't watch, though. Or it's an extra hundred." + CRLF +
+		"214 The Dude: Okay... just give me a minute. I gotta go find a cash machine...",
+	68: "214 Nihilist: Ve vont ze mawney Lebowski!",
+	69: "214 Walter Sobchak: Eight-year-olds, dude.",
+	70: "214-The Dude: They peed on my rug, man!" + CRLF +
+		"214-Walter Sobchak: f**king Nazis." + CRLF +
+		"214-Donny: I don't know if they were Nazis, Walter..." + CRLF +
+		"214 Walter Sobchak: Shut the f**k up, Donny. They were threatening castration!",
+	71: "214 Jesus Quintana: I don't f**king care, it don't matter to Jesus.",
+	72: "214-The Dude: Where's my car?" + CRLF +
+		"214 Walter Sobchak: It was parked in a handicap zone, perhaps they towed it.",
+	73: "214-Bunny Lebowski: Uli doesn't care about anything. He's a Nihilist!" + CRLF +
+		"214 The Dude: Ah, that must be exhausting!",
+	74: "214 Walter Sobchak: Smoky this is not Nam this is Bowling there are rules.",
+	75: "214 Maude Lebowski: Vagina.",
+	76: "214-Jesus Quintana: Let me tell you something pendejo. You pull any of your crazy s**t with us." + CRLF +
+		"214-You flash your piece out on the lanes. I'll take it away from you and stick up your alps" + CRLF +
+		"214-and pull the f**king trigger 'til it goes click." + CRLF +
+		"214-The Dude: ...Jesus" + CRLF +
+		"214 Jesus Quintana: You said it man, nobody f**ks with the Jesus.",
+	77: "214-The Dude: You brought a f**king Pomeranian bowling?" + CRLF +
+		"214-Walter Sobchak: Bought it bowling? I didn't rent it shoes. " + CRLF +
+		"214 I'm not buying it a f**king beer. It's not taking your f**king turn, Dude.",
+	78: "214 Walter Sobchak: Mark it as a zero.",
+	79: "214-The Stranger: The Dude abides. I don't know about you, but I take comfort in that. " + CRLF +
+		"214 It's good knowing he's out there, the Dude, takin' 'er easy for all us sinners.",
+	80: "214 Walter Sobchak: Aw, f**k it Dude. Let's go bowling.",
+	81: "214 Walter Sobchak: Life does not stop and start at your convenience you miserable piece of s**t.",
+	82: "214 Walter Sobchak: I told that kraut a f**king thousand times that I don't roll on Shabbos!",
+	83: "214 Walter Sobchak: This is what happens when you find a stranger in the alps!",
+}}
+
+// GetQuote returns a random quote from The big Lebowski
+func GetQuote() string {
+	rand.Seed(time.Now().UnixNano())
+	return fmt.Sprintf("%s", quotes.m[rand.Intn(len(quotes.m))])
+}

+ 254 - 164
server.go

@@ -6,14 +6,16 @@ import (
 	"fmt"
 	"io"
 	"net"
+	"runtime"
 	"strings"
+	"sync"
+	"sync/atomic"
 	"time"
 
-	"runtime"
-
-	log "github.com/Sirupsen/logrus"
-
-	"sync"
+	"github.com/flashmob/go-guerrilla/backends"
+	"github.com/flashmob/go-guerrilla/envelope"
+	"github.com/flashmob/go-guerrilla/log"
+	"github.com/flashmob/go-guerrilla/response"
 )
 
 const (
@@ -31,72 +33,171 @@ const (
 	RFC2821LimitRecipients = 100
 )
 
+const (
+	// server has just been created
+	ServerStateNew = iota
+	// Server has just been stopped
+	ServerStateStopped
+	// Server has been started and is running
+	ServerStateRunning
+	// Server could not start due to an error
+	ServerStateStartError
+)
+
 // Server listens for SMTP clients on the port specified in its config
 type server struct {
-	config         *ServerConfig
-	backend        Backend
-	tlsConfig      *tls.Config
-	maxSize        int64
-	timeout        time.Duration
-	clientPool     *Pool
-	wg             sync.WaitGroup // for waiting to shutdown
-	listener       net.Listener
-	closedListener chan (bool)
+	configStore     atomic.Value // stores guerrilla.ServerConfig
+	backend         backends.Backend
+	tlsConfigStore  atomic.Value
+	timeout         atomic.Value // stores time.Duration
+	listenInterface string
+	clientPool      *Pool
+	wg              sync.WaitGroup // for waiting to shutdown
+	listener        net.Listener
+	closedListener  chan (bool)
+	hosts           allowedHosts // stores map[string]bool for faster lookup
+	state           int
+	mainlog         log.Logger
+	log             log.Logger
+	// If log changed after a config reload, newLogStore stores the value here until it's safe to change it
+	logStore     atomic.Value
+	mainlogStore atomic.Value
+}
+
+type allowedHosts struct {
+	table map[string]bool // host lookup table
+	m     sync.Mutex      // guard access to the map
 }
 
 // Creates and returns a new ready-to-run Server from a configuration
-func newServer(sc ServerConfig, b *Backend) (*server, error) {
+func newServer(sc *ServerConfig, b backends.Backend, l log.Logger) (*server, error) {
 	server := &server{
-		config:         &sc,
-		backend:        *b,
-		maxSize:        sc.MaxSize,
-		timeout:        time.Duration(sc.Timeout),
-		clientPool:     NewPool(sc.MaxClients),
-		closedListener: make(chan (bool), 1),
+		backend:         b,
+		clientPool:      NewPool(sc.MaxClients),
+		closedListener:  make(chan (bool), 1),
+		listenInterface: sc.ListenInterface,
+		state:           ServerStateNew,
+		mainlog:         l,
+	}
+	var logOpenError error
+	if sc.LogFile == "" {
+		// none set, use the same log file as mainlog
+		server.log, logOpenError = log.GetLogger(server.mainlog.GetLogDest())
+	} else {
+		server.log, logOpenError = log.GetLogger(sc.LogFile)
+	}
+	if logOpenError != nil {
+		server.log.WithError(logOpenError).Errorf("Failed creating a logger for server [%s]", sc.ListenInterface)
 	}
-	if server.config.TLSAlwaysOn || server.config.StartTLSOn {
-		cert, err := tls.LoadX509KeyPair(server.config.PublicKeyFile, server.config.PrivateKeyFile)
+
+	// set to same level
+	server.log.SetLevel(server.mainlog.GetLevel())
+
+	server.setConfig(sc)
+	server.setTimeout(sc.Timeout)
+	if err := server.configureSSL(); err != nil {
+		return server, err
+	}
+	return server, nil
+}
+
+func (s *server) configureSSL() error {
+	sConfig := s.configStore.Load().(ServerConfig)
+	if sConfig.TLSAlwaysOn || sConfig.StartTLSOn {
+		cert, err := tls.LoadX509KeyPair(sConfig.PublicKeyFile, sConfig.PrivateKeyFile)
 		if err != nil {
-			return nil, fmt.Errorf("Error loading TLS certificate: %s", err.Error())
+			return fmt.Errorf("error while loading the certificate: %s", err)
 		}
-		server.tlsConfig = &tls.Config{
+		tlsConfig := &tls.Config{
 			Certificates: []tls.Certificate{cert},
 			ClientAuth:   tls.VerifyClientCertIfGiven,
-			ServerName:   server.config.Hostname,
-			Rand:         rand.Reader,
+			ServerName:   sConfig.Hostname,
 		}
+		tlsConfig.Rand = rand.Reader
+		s.tlsConfigStore.Store(tlsConfig)
 	}
-	return server, nil
+	return nil
+}
+
+// configureLog checks to see if there is a new logger, so that the server.log can be safely changed
+// this function is not gorotine safe, although it'll read the new value safely
+func (s *server) configureLog() {
+	// when log changed
+	if l, ok := s.logStore.Load().(log.Logger); ok {
+		if l != s.log {
+			s.log = l
+		}
+	}
+	// when mainlog changed
+	if ml, ok := s.mainlogStore.Load().(log.Logger); ok {
+		if ml != s.mainlog {
+			s.mainlog = ml
+		}
+	}
+}
+
+// Set the timeout for the server and all clients
+func (server *server) setTimeout(seconds int) {
+	duration := time.Duration(int64(seconds))
+	server.clientPool.SetTimeout(duration)
+	server.timeout.Store(duration)
+}
+
+// goroutine safe config store
+func (server *server) setConfig(sc *ServerConfig) {
+	server.configStore.Store(*sc)
 }
 
-// Begin accepting SMTP clients
+// goroutine safe
+func (server *server) isEnabled() bool {
+	sc := server.configStore.Load().(ServerConfig)
+	return sc.IsEnabled
+}
+
+// Set the allowed hosts for the server
+func (server *server) setAllowedHosts(allowedHosts []string) {
+	defer server.hosts.m.Unlock()
+	server.hosts.m.Lock()
+	server.hosts.table = make(map[string]bool, len(allowedHosts))
+	for _, h := range allowedHosts {
+		server.hosts.table[strings.ToLower(h)] = true
+	}
+}
+
+// Begin accepting SMTP clients. Will block unless there is an error or server.Shutdown() is called
 func (server *server) Start(startWG *sync.WaitGroup) error {
 	var clientID uint64
 	clientID = 0
 
-	listener, err := net.Listen("tcp", server.config.ListenInterface)
+	listener, err := net.Listen("tcp", server.listenInterface)
 	server.listener = listener
 	if err != nil {
-		return fmt.Errorf("[%s] Cannot listen on port: %s ", server.config.ListenInterface, err.Error())
+		startWG.Done() // don't wait for me
+		server.state = ServerStateStartError
+		return fmt.Errorf("[%s] Cannot listen on port: %s ", server.listenInterface, err.Error())
 	}
 
-	log.Infof("Listening on TCP %s", server.config.ListenInterface)
-	startWG.Done() // start successful
+	server.log.Infof("Listening on TCP %s", server.listenInterface)
+	server.state = ServerStateRunning
+	startWG.Done() // start successful, don't wait for me
 
 	for {
-		log.Debugf("[%s] Waiting for a new client. Next Client ID: %d", server.config.ListenInterface, clientID+1)
+		server.log.Debugf("[%s] Waiting for a new client. Next Client ID: %d", server.listenInterface, clientID+1)
 		conn, err := listener.Accept()
+		server.configureLog()
 		clientID++
 		if err != nil {
 			if e, ok := err.(net.Error); ok && !e.Temporary() {
-				log.Infof("Server [%s] has stopped accepting new clients", server.config.ListenInterface)
+				server.log.Infof("Server [%s] has stopped accepting new clients", server.listenInterface)
 				// the listener has been closed, wait for clients to exit
-				log.Infof("shutting down pool [%s]", server.config.ListenInterface)
+				server.log.Infof("shutting down pool [%s]", server.listenInterface)
+				server.clientPool.ShutdownState()
 				server.clientPool.ShutdownWait()
+				server.state = ServerStateStopped
 				server.closedListener <- true
 				return nil
 			}
-			log.WithError(err).Info("Temporary error accepting client")
+			server.mainlog.WithError(err).Info("Temporary error accepting client")
 			continue
 		}
 		go func(p Poolable, borrow_err error) {
@@ -105,28 +206,30 @@ func (server *server) Start(startWG *sync.WaitGroup) error {
 				server.handleClient(c)
 				server.clientPool.Return(c)
 			} else {
-				log.WithError(borrow_err).Info("couldn't borrow a new client")
+				server.log.WithError(borrow_err).Info("couldn't borrow a new client")
 				// we could not get a client, so close the connection.
 				conn.Close()
 
 			}
 			// intentionally placed Borrow in args so that it's called in the
 			// same main goroutine.
-		}(server.clientPool.Borrow(conn, clientID))
+		}(server.clientPool.Borrow(conn, clientID, server.log))
 
 	}
 }
 
 func (server *server) Shutdown() {
-	server.clientPool.ShutdownState()
 	if server.listener != nil {
+		// This will cause Start function to return, by causing an error on listener.Accept
 		server.listener.Close()
-		// wait for the listener to close.
+		// wait for the listener to listener.Accept
 		<-server.closedListener
 		// At this point Start will exit and close down the pool
 	} else {
+		server.clientPool.ShutdownState()
 		// listener already closed, wait for clients to exit
 		server.clientPool.ShutdownWait()
+		server.state = ServerStateStopped
 	}
 }
 
@@ -136,91 +239,41 @@ func (server *server) GetActiveClientsCount() int {
 
 // Verifies that the host is a valid recipient.
 func (server *server) allowsHost(host string) bool {
-	for _, allowed := range server.config.AllowedHosts {
-		if host == allowed {
-			return true
-		}
+	defer server.hosts.m.Unlock()
+	server.hosts.m.Lock()
+	if _, ok := server.hosts.table[strings.ToLower(host)]; ok {
+		return true
 	}
 	return false
 }
 
-// Upgrades a client connection to TLS
-func (server *server) upgradeToTLS(client *client) bool {
-	tlsConn := tls.Server(client.conn, server.tlsConfig)
-	err := tlsConn.Handshake()
-	if err != nil {
-		log.WithError(err).Warn("[%s] Failed TLS handshake", client.RemoteAddress)
-		return false
-	}
-	client.conn = net.Conn(tlsConn)
-	client.bufout.Reset(client.conn)
-	client.bufin.Reset(client.conn)
-	client.TLS = true
-
-	return true
-}
-
-// Closes a client connection
-func (server *server) closeConn(client *client) {
-	client.conn.Close()
-	client.conn = nil
-	log.WithFields(map[string]interface{}{
-		"event":   "disconnect",
-		"address": client.RemoteAddress,
-		"helo":    client.Helo,
-		"id":      client.ID,
-	}).Info("Close client")
-}
-
 // Reads from the client until a terminating sequence is encountered,
 // or until a timeout occurs.
-func (server *server) read(client *client) (string, error) {
+func (server *server) readCommand(client *client, maxSize int64) (string, error) {
 	var input, reply string
 	var err error
-
 	// In command state, stop reading at line breaks
 	suffix := "\r\n"
-	if client.state == ClientData {
-		// In data state, stop reading at solo periods
-		suffix = "\r\n.\r\n"
-	}
-
 	for {
-		client.setTimeout(server.timeout)
+		client.setTimeout(server.timeout.Load().(time.Duration))
 		reply, err = client.bufin.ReadString('\n')
 		input = input + reply
-		if err == nil && client.state == ClientData {
-			if reply != "" {
-				// Extract the subject while we're at it
-				client.scanSubject(reply)
-			}
-			if int64(len(input)) > server.config.MaxSize {
-				return input, fmt.Errorf("Maximum DATA size exceeded (%d)", server.config.MaxSize)
-			}
-		}
 		if err != nil {
 			break
 		}
 		if strings.HasSuffix(input, suffix) {
+			// discard the suffix and stop reading
+			input = input[0 : len(input)-len(suffix)]
 			break
 		}
 	}
 	return input, err
 }
 
-// Writes a response to the client.
-func (server *server) writeResponse(client *client) error {
-	client.setTimeout(server.timeout)
-	size, err := client.bufout.WriteString(client.response)
-	if err != nil {
-		return err
-	}
-	err = client.bufout.Flush()
-	if err != nil {
-		return err
-	}
-	client.response = client.response[size:]
-	return nil
+// flushResponse a response to the client. Flushes the client.bufout buffer to the connection
+func (server *server) flushResponse(client *client) error {
+	client.setTimeout(server.timeout.Load().(time.Duration))
+	return client.bufout.Flush()
 }
 
 func (server *server) isShuttingDown() bool {
@@ -229,8 +282,9 @@ func (server *server) isShuttingDown() bool {
 
 // Handles an entire client SMTP exchange
 func (server *server) handleClient(client *client) {
-	defer server.closeConn(client)
-	log.WithFields(map[string]interface{}{
+	defer client.closeConn()
+	sc := server.configStore.Load().(ServerConfig)
+	server.log.WithFields(map[string]interface{}{
 		"event":   "connect",
 		"address": client.RemoteAddress,
 		"helo":    client.Helo,
@@ -239,26 +293,35 @@ func (server *server) handleClient(client *client) {
 
 	// Initial greeting
 	greeting := fmt.Sprintf("220 %s SMTP Guerrilla(%s) #%d (%d) %s gr:%d",
-		server.config.Hostname, Version, client.ID,
+		sc.Hostname, Version, client.ID,
 		server.clientPool.GetActiveClientsCount(), time.Now().Format(time.RFC3339), runtime.NumGoroutine())
 
-	helo := fmt.Sprintf("250 %s Hello", server.config.Hostname)
-	ehlo := fmt.Sprintf("250-%s Hello", server.config.Hostname)
+	helo := fmt.Sprintf("250 %s Hello", sc.Hostname)
+	// ehlo is a multi-line reply and need additional \r\n at the end
+	ehlo := fmt.Sprintf("250-%s Hello\r\n", sc.Hostname)
 
 	// Extended feature advertisements
-	messageSize := fmt.Sprintf("250-SIZE %d\r\n", server.config.MaxSize)
+	messageSize := fmt.Sprintf("250-SIZE %d\r\n", sc.MaxSize)
 	pipelining := "250-PIPELINING\r\n"
 	advertiseTLS := "250-STARTTLS\r\n"
+	advertiseEnhancedStatusCodes := "250-ENHANCEDSTATUSCODES\r\n"
+	// The last line doesn't need \r\n since string will be printed as a new line.
+	// Also, Last line has no dash -
 	help := "250 HELP"
 
-	if server.config.TLSAlwaysOn {
-		success := server.upgradeToTLS(client)
-		if !success {
+	if sc.TLSAlwaysOn {
+		tlsConfig, ok := server.tlsConfigStore.Load().(*tls.Config)
+		if !ok {
+			server.mainlog.Error("Failed to load *tls.Config")
+		} else if err := client.upgradeToTLS(tlsConfig); err == nil {
+			advertiseTLS = ""
+		} else {
+			server.log.WithError(err).Warnf("[%s] Failed TLS handshake", client.RemoteAddress)
+			// server requires TLS, but can't handshake
 			client.kill()
 		}
-		advertiseTLS = ""
 	}
-	if !server.config.StartTLSOn {
+	if !sc.StartTLSOn {
 		// STARTTLS turned off, don't advertise it
 		advertiseTLS = ""
 	}
@@ -266,24 +329,24 @@ func (server *server) handleClient(client *client) {
 	for client.isAlive() {
 		switch client.state {
 		case ClientGreeting:
-			client.responseAdd(greeting)
+			client.sendResponse(greeting)
 			client.state = ClientCmd
 		case ClientCmd:
 			client.bufin.setLimit(CommandLineMaxLength)
-			input, err := server.read(client)
-			log.Debugf("Client sent: %s", input)
+			input, err := server.readCommand(client, sc.MaxSize)
+			server.log.Debugf("Client sent: %s", input)
 			if err == io.EOF {
-				log.WithError(err).Warnf("Client closed the connection: %s", client.RemoteAddress)
+				server.log.WithError(err).Warnf("Client closed the connection: %s", client.RemoteAddress)
 				return
 			} else if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
-				log.WithError(err).Warnf("Timeout: %s", client.RemoteAddress)
+				server.log.WithError(err).Warnf("Timeout: %s", client.RemoteAddress)
 				return
 			} else if err == LineLimitExceeded {
-				client.responseAdd("500 Line too long.")
+				client.sendResponse(response.Canned.FailLineTooLong)
 				client.kill()
 				break
 			} else if err != nil {
-				log.WithError(err).Warnf("Read error: %s", client.RemoteAddress)
+				server.log.WithError(err).Warnf("Read error: %s", client.RemoteAddress)
 				client.kill()
 				break
 			}
@@ -298,120 +361,139 @@ func (server *server) handleClient(client *client) {
 				cmdLen = CommandVerbMaxLength
 			}
 			cmd := strings.ToUpper(input[:cmdLen])
-
 			switch {
 			case strings.Index(cmd, "HELO") == 0:
 				client.Helo = strings.Trim(input[4:], " ")
 				client.resetTransaction()
-				client.responseAdd(helo)
+				client.sendResponse(helo)
 
 			case strings.Index(cmd, "EHLO") == 0:
 				client.Helo = strings.Trim(input[4:], " ")
 				client.resetTransaction()
-				client.responseAdd(ehlo + messageSize + pipelining + advertiseTLS + help)
+				client.sendResponse(ehlo,
+					messageSize,
+					pipelining,
+					advertiseTLS,
+					advertiseEnhancedStatusCodes,
+					help)
 
 			case strings.Index(cmd, "HELP") == 0:
-				client.responseAdd("214 OK\r\n" + messageSize + pipelining + advertiseTLS + help)
+				quote := response.GetQuote()
+				client.sendResponse("214-OK\r\n" + quote)
 
 			case strings.Index(cmd, "MAIL FROM:") == 0:
 				if client.isInTransaction() {
-					client.responseAdd("503 Error: nested MAIL command")
+					client.sendResponse(response.Canned.FailNestedMailCmd)
 					break
 				}
-				from, err := extractEmail(input[10:])
+				mail := input[10:]
+				from := envelope.EmailAddress{}
+
+				if !(strings.Index(mail, "<>") == 0) &&
+					!(strings.Index(mail, " <>") == 0) {
+					// Not Bounce, extract mail.
+					from, err = extractEmail(mail)
+				}
+
 				if err != nil {
-					client.responseAdd(err.Error())
+					client.sendResponse(err)
 				} else {
 					client.MailFrom = from
-					client.responseAdd("250 OK")
+					client.sendResponse(response.Canned.SuccessMailCmd)
 				}
 
 			case strings.Index(cmd, "RCPT TO:") == 0:
 				if len(client.RcptTo) > RFC2821LimitRecipients {
-					client.responseAdd("452 Too many recipients")
+					client.sendResponse(response.Canned.ErrorTooManyRecipients)
 					break
 				}
 				to, err := extractEmail(input[8:])
 				if err != nil {
-					client.responseAdd(err.Error())
+					client.sendResponse(err.Error())
 				} else {
 					if !server.allowsHost(to.Host) {
-						client.responseAdd("454 Error: Relay access denied: " + to.Host)
+						client.sendResponse(response.Canned.ErrorRelayDenied, to.Host)
 					} else {
-						client.RcptTo = append(client.RcptTo, *to)
-						client.responseAdd("250 OK")
+						client.RcptTo = append(client.RcptTo, to)
+						client.sendResponse(response.Canned.SuccessRcptCmd)
 					}
 				}
 
 			case strings.Index(cmd, "RSET") == 0:
 				client.resetTransaction()
-				client.responseAdd("250 OK")
+				client.sendResponse(response.Canned.SuccessResetCmd)
 
 			case strings.Index(cmd, "VRFY") == 0:
-				client.responseAdd("252 Cannot verify user")
+				client.sendResponse(response.Canned.SuccessVerifyCmd)
 
 			case strings.Index(cmd, "NOOP") == 0:
-				client.responseAdd("250 OK")
+				client.sendResponse(response.Canned.SuccessNoopCmd)
 
 			case strings.Index(cmd, "QUIT") == 0:
-				client.responseAdd("221 Bye")
+				client.sendResponse(response.Canned.SuccessQuitCmd)
 				client.kill()
 
 			case strings.Index(cmd, "DATA") == 0:
-				if client.MailFrom.isEmpty() {
-					client.responseAdd("503 Error: No sender")
+				if client.MailFrom.IsEmpty() {
+					client.sendResponse(response.Canned.FailNoSenderDataCmd)
 					break
 				}
 				if len(client.RcptTo) == 0 {
-					client.responseAdd("503 Error: No recipients")
+					client.sendResponse(response.Canned.FailNoRecipientsDataCmd)
 					break
 				}
-				client.responseAdd("354 Enter message, ending with '.' on a line by itself")
+				client.sendResponse(response.Canned.SuccessDataCmd)
 				client.state = ClientData
 
-			case server.config.StartTLSOn && strings.Index(cmd, "STARTTLS") == 0:
-				client.responseAdd("220 Ready to start TLS")
+			case sc.StartTLSOn && strings.Index(cmd, "STARTTLS") == 0:
+
+				client.sendResponse(response.Canned.SuccessStartTLSCmd)
 				client.state = ClientStartTLS
 			default:
-
-				client.responseAdd("500 Unrecognized command: " + cmd)
 				client.errors++
-				if client.errors > MaxUnrecognizedCommands {
-					client.responseAdd("554 Too many unrecognized commands")
+				if client.errors >= MaxUnrecognizedCommands {
+					client.sendResponse(response.Canned.FailMaxUnrecognizedCmd)
 					client.kill()
+				} else {
+					client.sendResponse(response.Canned.FailUnrecognizedCmd)
 				}
 			}
 
 		case ClientData:
-			var err error
 
-			client.bufin.setLimit(server.config.MaxSize)
-			client.Data, err = server.read(client)
+			// intentionally placed the limit 1MB above so that reading does not return with an error
+			// if the client goes a little over. Anything above will err
+			client.bufin.setLimit(int64(sc.MaxSize) + 1024000) // This a hard limit.
+
+			n, err := client.Data.ReadFrom(client.smtpReader.DotReader())
+			if n > sc.MaxSize {
+				err = fmt.Errorf("Maximum DATA size exceeded (%d)", sc.MaxSize)
+			}
 			if err != nil {
 				if err == LineLimitExceeded {
-					client.responseAdd("550 Error: " + LineLimitExceeded.Error())
+					client.sendResponse(response.Canned.FailReadLimitExceededDataCmd, LineLimitExceeded.Error())
 					client.kill()
 				} else if err == MessageSizeExceeded {
-					client.responseAdd("550 Error: " + MessageSizeExceeded.Error())
+					client.sendResponse(response.Canned.FailMessageSizeExceeded, MessageSizeExceeded.Error())
 					client.kill()
 				} else {
+					client.sendResponse(response.Canned.FailReadErrorDataCmd, err.Error())
 					client.kill()
-					client.responseAdd("451 Error: " + err.Error())
 				}
-				log.WithError(err).Warn("Error reading data")
+				server.log.WithError(err).Warn("Error reading data")
 				break
 			}
 
 			res := server.backend.Process(client.Envelope)
 			if res.Code() < 300 {
 				client.messagesSent++
-				log.WithFields(map[string]interface{}{
+				server.log.WithFields(map[string]interface{}{
 					"helo":          client.Helo,
 					"remoteAddress": client.RemoteAddress,
 					"success":       true,
 				}).Info("Received message")
 			}
-			client.responseAdd(res.String())
+			client.sendResponse(res.String())
 			client.state = ClientCmd
 			if server.isShuttingDown() {
 				client.state = ClientShutdown
@@ -419,25 +501,33 @@ func (server *server) handleClient(client *client) {
 			client.resetTransaction()
 
 		case ClientStartTLS:
-			if !client.TLS && server.config.StartTLSOn {
-				if server.upgradeToTLS(client) {
+			if !client.TLS && sc.StartTLSOn {
+				tlsConfig, ok := server.tlsConfigStore.Load().(*tls.Config)
+				if !ok {
+					server.mainlog.Error("Failed to load *tls.Config")
+				} else if err := client.upgradeToTLS(tlsConfig); err == nil {
 					advertiseTLS = ""
 					client.resetTransaction()
+				} else {
+					server.log.WithError(err).Warnf("[%s] Failed TLS handshake", client.RemoteAddress)
+					// Don't disconnect, let the client decide if it wants to continue
 				}
 			}
 			// change to command state
 			client.state = ClientCmd
 		case ClientShutdown:
 			// shutdown state
-			client.responseAdd("421 Server is shutting down. Please try again later. Sayonara!")
+			client.sendResponse(response.Canned.ErrorShutdown)
 			client.kill()
 		}
 
-		if len(client.response) > 0 {
-			log.Debugf("Writing response to client: \n%s", client.response)
-			err := server.writeResponse(client)
+		if client.bufout.Buffered() > 0 {
+			if server.log.IsDebug() {
+				server.log.Debugf("Writing response to client: \n%s", client.response.String())
+			}
+			err := server.flushResponse(client)
 			if err != nil {
-				log.WithError(err).Debug("Error writing response")
+				server.log.WithError(err).Debug("Error writing response")
 				return
 			}
 		}

+ 102 - 0
server_test.go

@@ -0,0 +1,102 @@
+package guerrilla
+
+import (
+	"testing"
+
+	"bufio"
+	"fmt"
+	"net/textproto"
+	"strings"
+	"sync"
+
+	"github.com/flashmob/go-guerrilla/backends"
+	"github.com/flashmob/go-guerrilla/log"
+	"github.com/flashmob/go-guerrilla/mocks"
+)
+
+// getMockServerConfig gets a mock ServerConfig struct used for creating a new server
+func getMockServerConfig() *ServerConfig {
+	sc := &ServerConfig{
+		IsEnabled:       true, // not tested here
+		Hostname:        "saggydimes.test.com",
+		MaxSize:         1024, // smtp message max size
+		PrivateKeyFile:  "./tests/mail.guerrillamail.com.key.pem",
+		PublicKeyFile:   "./tests/mail.guerrillamail.com.cert.pem",
+		Timeout:         5,
+		ListenInterface: "127.0.0.1:2529",
+		StartTLSOn:      true,
+		TLSAlwaysOn:     false,
+		MaxClients:      30, // not tested here
+		LogFile:         "./tests/testlog",
+	}
+	return sc
+}
+
+// getMockServerConn gets a new server using sc. Server will be using a mocked TCP connection
+// using the dummy backend
+// RCP TO command only allows test.com host
+func getMockServerConn(sc *ServerConfig, t *testing.T) (*mocks.Conn, *server) {
+	var logOpenError error
+	var mainlog log.Logger
+	mainlog, logOpenError = log.GetLogger(sc.LogFile)
+	if logOpenError != nil {
+		mainlog.WithError(logOpenError).Errorf("Failed creating a logger for mock conn [%s]", sc.ListenInterface)
+	}
+	backend, err := backends.New("dummy", backends.BackendConfig{"log_received_mails": true}, mainlog)
+	if err != nil {
+		t.Error("new dummy backend failed because:", err)
+	}
+	server, err := newServer(sc, backend, mainlog)
+	if err != nil {
+		//t.Error("new server failed because:", err)
+	} else {
+		server.setAllowedHosts([]string{"test.com"})
+	}
+	conn := mocks.NewConn()
+	return conn, server
+}
+
+func TestHandleClient(t *testing.T) {
+	var mainlog log.Logger
+	var logOpenError error
+	sc := getMockServerConfig()
+	mainlog, logOpenError = log.GetLogger(sc.LogFile)
+	if logOpenError != nil {
+		mainlog.WithError(logOpenError).Errorf("Failed creating a logger for mock conn [%s]", sc.ListenInterface)
+	}
+	conn, server := getMockServerConn(sc, t)
+	// call the serve.handleClient() func in a goroutine.
+	client := NewClient(conn.Server, 1, mainlog)
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		server.handleClient(client)
+		wg.Done()
+	}()
+	// Wait for the greeting from the server
+	r := textproto.NewReader(bufio.NewReader(conn.Client))
+	line, _ := r.ReadLine()
+	fmt.Println(line)
+	w := textproto.NewWriter(bufio.NewWriter(conn.Client))
+	w.PrintfLine("HELO test.test.com")
+	line, _ = r.ReadLine()
+	fmt.Println(line)
+	w.PrintfLine("QUIT")
+	line, _ = r.ReadLine()
+	fmt.Println("line is:", line)
+	expected := "221 2.0.0 Bye"
+	if strings.Index(line, expected) != 0 {
+		t.Error("expected", expected, "but got:", line)
+	}
+	wg.Wait() // wait for handleClient to exit
+}
+
+// TODO
+// - test github issue #44 and #42
+// - test other commands
+
+// also, could test
+// - test allowsHost() and allowsHost()
+// - test isInTransaction() (make sure it returns true after MAIL command, but false after HELO/EHLO/RSET/end of DATA
+// - test to make sure client envelope
+// - perhaps anything else that can be tested in server_test.go

+ 47 - 0
tests/client.go

@@ -0,0 +1,47 @@
+package test
+
+import (
+	"bufio"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"github.com/flashmob/go-guerrilla"
+	"net"
+	"time"
+)
+
+func Connect(serverConfig guerrilla.ServerConfig, deadline time.Duration) (net.Conn, *bufio.Reader, error) {
+	var bufin *bufio.Reader
+	var conn net.Conn
+	var err error
+	if serverConfig.TLSAlwaysOn {
+		// start tls automatically
+		conn, err = tls.Dial("tcp", serverConfig.ListenInterface, &tls.Config{
+			InsecureSkipVerify: true,
+			ServerName:         "127.0.0.1",
+		})
+	} else {
+		conn, err = net.Dial("tcp", serverConfig.ListenInterface)
+	}
+
+	if err != nil {
+		// handle error
+		//t.Error("Cannot dial server", config.Servers[0].ListenInterface)
+		return conn, bufin, errors.New("Cannot dial server: " + serverConfig.ListenInterface + "," + err.Error())
+	}
+	bufin = bufio.NewReader(conn)
+
+	// should be ample time to complete the test
+	conn.SetDeadline(time.Now().Add(time.Duration(time.Second * deadline)))
+	// read greeting, ignore it
+	_, err = bufin.ReadString('\n')
+	return conn, bufin, err
+}
+
+func Command(conn net.Conn, bufin *bufio.Reader, command string) (reply string, err error) {
+	_, err = fmt.Fprintln(conn, command+"\r")
+	if err == nil {
+		return bufin.ReadString('\n')
+	}
+	return "", err
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 520 - 166
tests/guerrilla_test.go


+ 6 - 6
tests/generate_cert.go → tests/testcert/generate_cert.go

@@ -1,9 +1,8 @@
 // adopted from https://golang.org/src/crypto/tls/generate_cert.go?m=text
 
-// Generate a self-signed X.509 certificate for a TLS server. Outputs to
-// 'cert.pem' and 'key.pem' and will overwrite existing files.
+// Generate a self-signed X.509 certificate for a TLS server.
 
-package test
+package testcert
 
 import (
 	"crypto/ecdsa"
@@ -63,7 +62,7 @@ func pemBlockForKey(priv interface{}) *pem.Block {
 
 // validFrom - Creation date formatted as Jan 1 15:04:05 2011 or ""
 
-func generateCert(host string, validFrom string, validFor time.Duration, isCA bool, rsaBits int, ecdsaCurve string) {
+func GenerateCert(host string, validFrom string, validFor time.Duration, isCA bool, rsaBits int, ecdsaCurve string, dirPrefix string) {
 
 	if len(host) == 0 {
 		log.Fatalf("Missing required --host parameter")
@@ -141,19 +140,20 @@ func generateCert(host string, validFrom string, validFor time.Duration, isCA bo
 		log.Fatalf("Failed to create certificate: %s", err)
 	}
 
-	certOut, err := os.Create("./" + host + ".cert.pem")
+	certOut, err := os.Create(dirPrefix + host + ".cert.pem")
 	if err != nil {
 		log.Fatalf("failed to open cert.pem for writing: %s", err)
 	}
 	pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
 	certOut.Close()
 
-	keyOut, err := os.OpenFile("./"+host+".key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+	keyOut, err := os.OpenFile(dirPrefix+host+".key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
 	if err != nil {
 		log.Print("failed to open key.pem for writing:", err)
 		return
 	}
 	pem.Encode(keyOut, pemBlockForKey(priv))
+	keyOut.Sync()
 	keyOut.Close()
 
 }

+ 9 - 6
util.go

@@ -4,15 +4,18 @@ import (
 	"errors"
 	"regexp"
 	"strings"
+
+	"github.com/flashmob/go-guerrilla/envelope"
+	"github.com/flashmob/go-guerrilla/response"
 )
 
 var extractEmailRegex, _ = regexp.Compile(`<(.+?)@(.+?)>`) // go home regex, you're drunk!
 
-func extractEmail(str string) (*EmailAddress, error) {
-	email := &EmailAddress{}
+func extractEmail(str string) (envelope.EmailAddress, error) {
+	email := envelope.EmailAddress{}
 	var err error
 	if len(str) > RFC2821LimitPath {
-		return email, errors.New("501 Path too long")
+		return email, errors.New(response.Canned.FailPathTooLong)
 	}
 	if matched := extractEmailRegex.FindStringSubmatch(str); len(matched) > 2 {
 		email.User = matched[1]
@@ -23,11 +26,11 @@ func extractEmail(str string) (*EmailAddress, error) {
 	}
 	err = nil
 	if email.User == "" || email.Host == "" {
-		err = errors.New("501 Invalid address")
+		err = errors.New(response.Canned.FailInvalidAddress)
 	} else if len(email.User) > RFC2832LimitLocalPart {
-		err = errors.New("501 Local part too long, cannot exceed 64 characters")
+		err = errors.New(response.Canned.FailLocalPartTooLong)
 	} else if len(email.Host) > RFC2821LimitDomain {
-		err = errors.New("501 Domain cannot exceed 255 characters")
+		err = errors.New(response.Canned.FailDomainTooLong)
 	}
 	return email, err
 }

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.