├── tests ├── .gitignore ├── client.go └── testcert │ └── generate_cert.go ├── GoGuerrilla.png ├── .gitignore ├── cmd └── guerrillad │ ├── main.go │ ├── version.go │ ├── root.go │ └── serve.go ├── .travis.gofmt.sh ├── tls_go1.14.go ├── backends ├── storage │ └── redigo │ │ └── driver.go ├── decorate.go ├── validate.go ├── p_guerrilla_db_redis_test.go ├── redis_generic.go ├── p_headers_parser.go ├── p_redis_test.go ├── processor.go ├── util.go ├── p_hasher.go ├── p_debugger.go ├── p_sql_test.go ├── p_header.go ├── gateway_test.go ├── p_compressor.go ├── p_redis.go ├── backend.go ├── p_sql.go ├── p_guerrilla_db_redis.go └── gateway.go ├── .travis.yml ├── guerrilla_unix.go ├── guerrilla_notunix.go ├── tls_go1.13.go ├── version.go ├── mail ├── encoding │ ├── encoding.go │ └── encoding_test.go ├── iconv │ ├── iconv_test.go │ └── iconv.go ├── rfc5321 │ ├── address_test.go │ └── address.go ├── envelope_test.go └── envelope.go ├── tls_go1.8.go ├── Gopkg.toml ├── LICENSE ├── mocks ├── client.go └── conn_mock.go ├── Makefile ├── response ├── enhanced_test.go └── quote.go ├── models.go ├── event.go ├── goguerrilla.conf.sample ├── Gopkg.lock ├── log ├── hook.go └── log.go ├── pool.go ├── api.go ├── client.go ├── config_test.go └── README.md /tests/.gitignore: -------------------------------------------------------------------------------- 1 | *.pem -------------------------------------------------------------------------------- /GoGuerrilla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flashmob/go-guerrilla/HEAD/GoGuerrilla.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | goguerrilla.conf 3 | goguerrilla.conf.json 4 | /guerrillad 5 | vendor 6 | go-guerrilla.wiki -------------------------------------------------------------------------------- /cmd/guerrillad/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | if err := rootCmd.Execute(); err != nil { 10 | fmt.Println(err) 11 | os.Exit(-1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.travis.gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -n $(find . -path '*/vendor/*' -prune -o -path '*.glide/*' -prune -o -name '*.go' -type f -exec gofmt -l {} \;) ]]; then 4 | echo "Go code is not formatted:" 5 | gofmt -d . 6 | exit 1 7 | fi -------------------------------------------------------------------------------- /tls_go1.14.go: -------------------------------------------------------------------------------- 1 | // +build !go1.14 2 | 3 | package guerrilla 4 | 5 | import "crypto/tls" 6 | 7 | func init() { 8 | 9 | TLSProtocols["ssl3.0"] = tls.VersionSSL30 // deprecated since GO 1.13, removed 1.14 10 | 11 | // Include to prevent downgrade attacks (SSLv3 only, deprecated in Go 1.13) 12 | TLSCiphers["TLS_FALLBACK_SCSV"] = tls.TLS_FALLBACK_SCSV 13 | } 14 | -------------------------------------------------------------------------------- /backends/storage/redigo/driver.go: -------------------------------------------------------------------------------- 1 | package redigo_driver 2 | 3 | import "github.com/flashmob/go-guerrilla/backends" 4 | import redigo "github.com/gomodule/redigo/redis" 5 | 6 | func init() { 7 | backends.RedisDialer = func(network, address string, options ...backends.RedisDialOption) (backends.RedisConn, error) { 8 | return redigo.Dial(network, address) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: 4 | - 1.11.x 5 | - 1.12.x 6 | - 1.13.x 7 | - master 8 | 9 | cache: 10 | directories: 11 | - $HOME/.cache/go-build 12 | - $HOME/gopath/pkg/mod 13 | 14 | install: 15 | - go get -u github.com/golang/dep/cmd/dep 16 | - dep ensure 17 | 18 | script: 19 | - ./.travis.gofmt.sh 20 | - make guerrillad 21 | - make test 22 | -------------------------------------------------------------------------------- /backends/decorate.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | // We define what a decorator to our processor will look like 4 | type Decorator func(Processor) Processor 5 | 6 | // Decorate will decorate a processor with a slice of passed decorators 7 | func Decorate(c Processor, ds ...Decorator) Processor { 8 | decorated := c 9 | for _, decorate := range ds { 10 | decorated = decorate(decorated) 11 | } 12 | return decorated 13 | } 14 | -------------------------------------------------------------------------------- /guerrilla_unix.go: -------------------------------------------------------------------------------- 1 | // +build darwin dragonfly freebsd linux netbsd openbsd 2 | 3 | package guerrilla 4 | 5 | import "syscall" 6 | 7 | // getFileLimit checks how many files we can open 8 | func getFileLimit() (uint64, error) { 9 | var rLimit syscall.Rlimit 10 | err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit) 11 | if err != nil { 12 | return 0, err 13 | } 14 | //unnecessary type conversions to uint64 is needed for FreeBSD 15 | return uint64(rLimit.Max), nil 16 | } 17 | -------------------------------------------------------------------------------- /guerrilla_notunix.go: -------------------------------------------------------------------------------- 1 | // +build !darwin 2 | // +build !dragonfly 3 | // +build !freebsd 4 | // +build !linux 5 | // +build !netbsd 6 | // +build !openbsd 7 | 8 | package guerrilla 9 | 10 | import "errors" 11 | 12 | // getFileLimit checks how many files we can open 13 | // Don't know how to get that info (yet?), so returns false information & error 14 | func getFileLimit() (uint64, error) { 15 | return 1000000, errors.New("syscall.RLIMIT_NOFILE not supported on your OS/platform") 16 | } 17 | -------------------------------------------------------------------------------- /tls_go1.13.go: -------------------------------------------------------------------------------- 1 | // +build go1.13 2 | 3 | package guerrilla 4 | 5 | import "crypto/tls" 6 | 7 | // TLS 1.3 was introduced in go 1.12 as an option and enabled for production in go 1.13 8 | // release notes: https://golang.org/doc/go1.12#tls_1_3 9 | func init() { 10 | TLSProtocols["tls1.3"] = tls.VersionTLS13 11 | 12 | TLSCiphers["TLS_AES_128_GCM_SHA256"] = tls.TLS_AES_128_GCM_SHA256 13 | TLSCiphers["TLS_AES_256_GCM_SHA384"] = tls.TLS_AES_256_GCM_SHA384 14 | TLSCiphers["TLS_CHACHA20_POLY1305_SHA256"] = tls.TLS_CHACHA20_POLY1305_SHA256 15 | } 16 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import "time" 4 | 5 | var ( 6 | Version string 7 | Commit string 8 | BuildTime string 9 | 10 | StartTime time.Time 11 | ConfigLoadTime time.Time 12 | ) 13 | 14 | func init() { 15 | // If version, commit, or build time are not set, make that clear. 16 | const unknown = "unknown" 17 | if Version == "" { 18 | Version = unknown 19 | } 20 | if Commit == "" { 21 | Commit = unknown 22 | } 23 | if BuildTime == "" { 24 | BuildTime = unknown 25 | } 26 | 27 | StartTime = time.Now() 28 | } 29 | -------------------------------------------------------------------------------- /backends/validate.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | type RcptError error 8 | 9 | var ( 10 | NoSuchUser = RcptError(errors.New("no such user")) 11 | StorageNotAvailable = RcptError(errors.New("storage not available")) 12 | StorageTooBusy = RcptError(errors.New("storage too busy")) 13 | StorageTimeout = RcptError(errors.New("storage timeout")) 14 | QuotaExceeded = RcptError(errors.New("quota exceeded")) 15 | UserSuspended = RcptError(errors.New("user suspended")) 16 | StorageError = RcptError(errors.New("storage error")) 17 | ) 18 | -------------------------------------------------------------------------------- /mail/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | // encoding enables using golang.org/x/net/html/charset for converting 7bit to UTF-8. 2 | // golang.org/x/net/html/charset supports a larger range of encodings. 3 | // when importing, place an underscore _ in front to import for side-effects 4 | 5 | package encoding 6 | 7 | import ( 8 | "io" 9 | 10 | "github.com/flashmob/go-guerrilla/mail" 11 | cs "golang.org/x/net/html/charset" 12 | ) 13 | 14 | func init() { 15 | 16 | mail.Dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { 17 | return cs.NewReaderLabel(charset, input) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /mail/iconv/iconv_test.go: -------------------------------------------------------------------------------- 1 | package iconv 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/mail" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // This will use the iconv encoder 10 | func TestIconvMimeHeaderDecode(t *testing.T) { 11 | str := mail.MimeHeaderDecode("=?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?=") 12 | if i := strings.Index(str, "【女子高生チャ"); i != 0 { 13 | t.Error("expecting 【女子高生チャ, got:", str) 14 | } 15 | str = mail.MimeHeaderDecode("=?ISO-8859-1?Q?Andr=E9?= Pirard ") 16 | if strings.Index(str, "André Pirard") != 0 { 17 | t.Error("expecting André Pirard, got:", str) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cmd/guerrillad/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/flashmob/go-guerrilla" 7 | ) 8 | 9 | var versionCmd = &cobra.Command{ 10 | Use: "version", 11 | Short: "Print the version info", 12 | Long: `Every software has a version. This is Guerrilla's`, 13 | Run: func(cmd *cobra.Command, args []string) { 14 | logVersion() 15 | }, 16 | } 17 | 18 | func init() { 19 | rootCmd.AddCommand(versionCmd) 20 | } 21 | 22 | func logVersion() { 23 | mainlog.Infof("guerrillad %s", guerrilla.Version) 24 | mainlog.Debugf("Build Time: %s", guerrilla.BuildTime) 25 | mainlog.Debugf("Commit: %s", guerrilla.Commit) 26 | } 27 | -------------------------------------------------------------------------------- /mail/iconv/iconv.go: -------------------------------------------------------------------------------- 1 | // iconv enables using GNU iconv for converting 7bit to UTF-8. 2 | // iconv supports a larger range of encodings. 3 | // It's a cgo package, the build system needs have Gnu library headers available. 4 | // when importing, place an underscore _ in front to import for side-effects 5 | package iconv 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | 11 | "github.com/flashmob/go-guerrilla/mail" 12 | ico "gopkg.in/iconv.v1" 13 | ) 14 | 15 | func init() { 16 | mail.Dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { 17 | if cd, err := ico.Open("UTF-8", charset); err == nil { 18 | r := ico.NewReader(cd, input, 32) 19 | return r, nil 20 | } 21 | return nil, fmt.Errorf("unhandled charset %q", charset) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /tls_go1.8.go: -------------------------------------------------------------------------------- 1 | // +build go1.8 2 | 3 | package guerrilla 4 | 5 | import "crypto/tls" 6 | 7 | // add ciphers introduced since Go 1.8 8 | func init() { 9 | TLSCiphers["TLS_RSA_WITH_AES_128_CBC_SHA256"] = tls.TLS_RSA_WITH_AES_128_CBC_SHA256 10 | TLSCiphers["TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256"] = tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 11 | TLSCiphers["TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256"] = tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 12 | TLSCiphers["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"] = tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 13 | TLSCiphers["TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305"] = tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 14 | TLSCiphers["TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305"] = tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 15 | 16 | TLSCurves["X25519"] = tls.X25519 17 | } 18 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | [[constraint]] 2 | name = "github.com/go-sql-driver/mysql" 3 | version = "1.3.0" 4 | 5 | [[constraint]] 6 | name = "github.com/gomodule/redigo" 7 | version = "~2.0.0" 8 | 9 | [[constraint]] 10 | name = "github.com/sirupsen/logrus" 11 | version = "~1.4.2" 12 | 13 | [[constraint]] 14 | branch = "master" 15 | name = "golang.org/x/net" 16 | 17 | [[constraint]] 18 | name = "gopkg.in/iconv.v1" 19 | version = "~1.1.1" 20 | 21 | [[constraint]] 22 | name = "github.com/asaskevich/EventBus" 23 | revision = "68a521d7cbbb7a859c2608b06342f384b3bd5f5a" 24 | 25 | # The following locks logrus to a particular version of x/sys 26 | [[override]] 27 | name = "golang.org/x/sys" 28 | revision = "7dca6fe1f43775aa6d1334576870ff63f978f539" 29 | 30 | [prune] 31 | go-tests = true 32 | unused-packages = true 33 | -------------------------------------------------------------------------------- /cmd/guerrillad/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var rootCmd = &cobra.Command{ 9 | Use: "guerrillad", 10 | Short: "small SMTP daemon", 11 | Long: `It's a small SMTP daemon written in Go, for the purpose of receiving large volumes of email. 12 | Written for GuerrillaMail.com which processes tens of thousands of emails every hour.`, 13 | Run: nil, 14 | } 15 | 16 | var ( 17 | verbose bool 18 | ) 19 | 20 | func init() { 21 | cobra.OnInitialize() 22 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, 23 | "print out more debug information") 24 | rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { 25 | if verbose { 26 | logrus.SetLevel(logrus.DebugLevel) 27 | } else { 28 | logrus.SetLevel(logrus.InfoLevel) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backends/p_guerrilla_db_redis_test.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestCompressedData(t *testing.T) { 13 | var b bytes.Buffer 14 | var out bytes.Buffer 15 | str := "Hello Hello Hello Hello Hello Hello Hello!" 16 | sbj := "Subject:hello\r\n" 17 | b.WriteString(str) 18 | cd := newCompressedData() 19 | cd.set([]byte(sbj), &b) 20 | 21 | // compress 22 | if _, err := fmt.Fprint(&out, cd); err != nil { 23 | t.Error(err) 24 | } 25 | 26 | // decompress 27 | var result bytes.Buffer 28 | zReader, _ := zlib.NewReader(bytes.NewReader(out.Bytes())) 29 | if _, err := io.Copy(&result, zReader); err != nil { 30 | t.Error(err) 31 | } 32 | expect := sbj + str 33 | if delta := strings.Compare(expect, result.String()); delta != 0 { 34 | t.Error(delta, "compression did match, expected", expect, "but got", result.String()) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 GuerrillaMail.com. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /backends/redis_generic.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | func init() { 9 | RedisDialer = func(network, address string, options ...RedisDialOption) (RedisConn, error) { 10 | return new(RedisMockConn), nil 11 | } 12 | } 13 | 14 | // RedisConn interface provides a generic way to access Redis via drivers 15 | type RedisConn interface { 16 | Close() error 17 | Do(commandName string, args ...interface{}) (reply interface{}, err error) 18 | } 19 | 20 | type RedisMockConn struct{} 21 | 22 | func (m *RedisMockConn) Close() error { 23 | return nil 24 | } 25 | 26 | func (m *RedisMockConn) Do(commandName string, args ...interface{}) (reply interface{}, err error) { 27 | Log().Info("redis mock driver command: ", commandName) 28 | return nil, nil 29 | } 30 | 31 | type dialOptions struct { 32 | readTimeout time.Duration 33 | writeTimeout time.Duration 34 | dial func(network, addr string) (net.Conn, error) 35 | db int 36 | password string 37 | } 38 | 39 | type RedisDialOption struct { 40 | f func(*dialOptions) 41 | } 42 | 43 | type redisDial func(network, address string, options ...RedisDialOption) (RedisConn, error) 44 | 45 | var RedisDialer redisDial 46 | -------------------------------------------------------------------------------- /mocks/client.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "fmt" 5 | "net/smtp" 6 | ) 7 | 8 | const ( 9 | URL = "127.0.0.1:2500" 10 | ) 11 | 12 | func lastWords(message string, err error) { 13 | fmt.Println(message, err.Error()) 14 | } 15 | 16 | func sendMail(i int) { 17 | fmt.Printf("Sending %d mail\n", i) 18 | c, err := smtp.Dial(URL) 19 | if err != nil { 20 | lastWords("Dial ", err) 21 | } 22 | defer func() { 23 | _ = c.Close() 24 | }() 25 | 26 | from := "somebody@gmail.com" 27 | to := "somebody.else@gmail.com" 28 | 29 | if err = c.Mail(from); err != nil { 30 | lastWords("Mail ", err) 31 | } 32 | 33 | if err = c.Rcpt(to); err != nil { 34 | lastWords("Rcpt ", err) 35 | } 36 | 37 | wr, err := c.Data() 38 | if err != nil { 39 | lastWords("Data ", err) 40 | } 41 | defer func() { 42 | _ = wr.Close() 43 | }() 44 | 45 | msg := fmt.Sprint("Subject: something\n") 46 | msg += "From: " + from + "\n" 47 | msg += "To: " + to + "\n" 48 | msg += "\n\n" 49 | msg += "hello\n" 50 | 51 | _, err = fmt.Fprint(wr, msg) 52 | if err != nil { 53 | lastWords("Send ", err) 54 | } 55 | 56 | fmt.Printf("About to quit %d\n", i) 57 | err = c.Quit() 58 | if err != nil { 59 | lastWords("Quit ", err) 60 | } 61 | fmt.Printf("Finished sending %d mail\n", i) 62 | } 63 | -------------------------------------------------------------------------------- /tests/client.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "github.com/flashmob/go-guerrilla" 9 | "net" 10 | "time" 11 | ) 12 | 13 | func Connect(serverConfig guerrilla.ServerConfig, deadline time.Duration) (net.Conn, *bufio.Reader, error) { 14 | var bufin *bufio.Reader 15 | var conn net.Conn 16 | var err error 17 | if serverConfig.TLS.AlwaysOn { 18 | // start tls automatically 19 | conn, err = tls.Dial("tcp", serverConfig.ListenInterface, &tls.Config{ 20 | InsecureSkipVerify: true, 21 | ServerName: "127.0.0.1", 22 | }) 23 | } else { 24 | conn, err = net.Dial("tcp", serverConfig.ListenInterface) 25 | } 26 | 27 | if err != nil { 28 | // handle error 29 | //t.Error("Cannot dial server", config.Servers[0].ListenInterface) 30 | return conn, bufin, errors.New("Cannot dial server: " + serverConfig.ListenInterface + "," + err.Error()) 31 | } 32 | bufin = bufio.NewReader(conn) 33 | 34 | // should be ample time to complete the test 35 | if err = conn.SetDeadline(time.Now().Add(time.Second * deadline)); err != nil { 36 | return conn, bufin, err 37 | } 38 | // read greeting, ignore it 39 | _, err = bufin.ReadString('\n') 40 | return conn, bufin, err 41 | } 42 | 43 | func Command(conn net.Conn, bufin *bufio.Reader, command string) (reply string, err error) { 44 | _, err = fmt.Fprintln(conn, command+"\r") 45 | if err == nil { 46 | return bufin.ReadString('\n') 47 | } 48 | return "", err 49 | } 50 | -------------------------------------------------------------------------------- /backends/p_headers_parser.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/mail" 5 | ) 6 | 7 | // ---------------------------------------------------------------------------------- 8 | // Processor Name: headersparser 9 | // ---------------------------------------------------------------------------------- 10 | // Description : Parses the header using e.ParseHeaders() 11 | // ---------------------------------------------------------------------------------- 12 | // Config Options: none 13 | // --------------:------------------------------------------------------------------- 14 | // Input : envelope 15 | // ---------------------------------------------------------------------------------- 16 | // Output : Headers will be populated in e.Header 17 | // ---------------------------------------------------------------------------------- 18 | func init() { 19 | processors["headersparser"] = func() Decorator { 20 | return HeadersParser() 21 | } 22 | } 23 | 24 | func HeadersParser() Decorator { 25 | return func(p Processor) Processor { 26 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 27 | if task == TaskSaveMail { 28 | if err := e.ParseHeaders(); err != nil { 29 | Log().WithError(err).Error("parse headers error") 30 | } 31 | // next processor 32 | return p.Process(e, task) 33 | } else { 34 | // next processor 35 | return p.Process(e, task) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT ?= git 2 | GO_VARS ?= 3 | GO ?= go 4 | COMMIT := $(shell $(GIT) rev-parse HEAD) 5 | VERSION ?= $(shell $(GIT) describe --tags ${COMMIT} 2> /dev/null || echo "$(COMMIT)") 6 | BUILD_TIME := $(shell LANG=en_US date +"%F_%T_%z") 7 | ROOT := github.com/flashmob/go-guerrilla 8 | LD_FLAGS := -X $(ROOT).Version=$(VERSION) -X $(ROOT).Commit=$(COMMIT) -X $(ROOT).BuildTime=$(BUILD_TIME) 9 | 10 | .PHONY: help clean dependencies test 11 | help: 12 | @echo "Please use \`make ' where is one of" 13 | @echo " guerrillad to build the main binary for current platform" 14 | @echo " test to run unittests" 15 | 16 | clean: 17 | rm -f guerrillad 18 | 19 | vendor: 20 | dep ensure 21 | 22 | guerrillad: 23 | $(GO_VARS) $(GO) build -o="guerrillad" -ldflags="$(LD_FLAGS)" $(ROOT)/cmd/guerrillad 24 | 25 | guerrilladrace: 26 | $(GO_VARS) $(GO) build -o="guerrillad" -race -ldflags="$(LD_FLAGS)" $(ROOT)/cmd/guerrillad 27 | 28 | test: 29 | $(GO_VARS) $(GO) test -v . 30 | $(GO_VARS) $(GO) test -v ./tests 31 | $(GO_VARS) $(GO) test -v ./cmd/guerrillad 32 | $(GO_VARS) $(GO) test -v ./response 33 | $(GO_VARS) $(GO) test -v ./backends 34 | $(GO_VARS) $(GO) test -v ./mail 35 | $(GO_VARS) $(GO) test -v ./mail/encoding 36 | $(GO_VARS) $(GO) test -v ./mail/rfc5321 37 | 38 | testrace: 39 | $(GO_VARS) $(GO) test -v . -race 40 | $(GO_VARS) $(GO) test -v ./tests -race 41 | $(GO_VARS) $(GO) test -v ./cmd/guerrillad -race 42 | $(GO_VARS) $(GO) test -v ./response -race 43 | $(GO_VARS) $(GO) test -v ./backends -race -------------------------------------------------------------------------------- /response/enhanced_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetBasicStatusCode(t *testing.T) { 8 | // Known status code 9 | a := getBasicStatusCode(EnhancedStatusCode{2, OtherOrUndefinedProtocolStatus}) 10 | if a != 250 { 11 | t.Errorf("getBasicStatusCode. Int \"%d\" not expected.", a) 12 | } 13 | 14 | // Unknown status code 15 | b := getBasicStatusCode(EnhancedStatusCode{2, OtherStatus}) 16 | if b != 200 { 17 | t.Errorf("getBasicStatusCode. Int \"%d\" not expected.", b) 18 | } 19 | } 20 | 21 | // TestString for the String function 22 | func TestCustomString(t *testing.T) { 23 | // Basic testing 24 | resp := &Response{ 25 | EnhancedCode: OtherStatus, 26 | BasicCode: 200, 27 | Class: ClassSuccess, 28 | Comment: "Test", 29 | } 30 | 31 | if resp.String() != "200 2.0.0 Test" { 32 | t.Errorf("CustomString failed. String \"%s\" not expected.", resp) 33 | } 34 | 35 | // Default String 36 | resp2 := &Response{ 37 | EnhancedCode: OtherStatus, 38 | Class: ClassSuccess, 39 | } 40 | if resp2.String() != "200 2.0.0 OK" { 41 | t.Errorf("String failed. String \"%s\" not expected.", resp2) 42 | } 43 | } 44 | 45 | func TestBuildEnhancedResponseFromDefaultStatus(t *testing.T) { 46 | //a := buildEnhancedResponseFromDefaultStatus(ClassPermanentFailure, InvalidCommand) 47 | a := EnhancedStatusCode{ClassPermanentFailure, InvalidCommand}.String() 48 | if a != "5.5.1" { 49 | t.Errorf("buildEnhancedResponseFromDefaultStatus failed. String \"%s\" not expected.", a) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backends/p_redis_test.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/log" 5 | "github.com/flashmob/go-guerrilla/mail" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestRedisGeneric(t *testing.T) { 13 | 14 | e := mail.NewEnvelope("127.0.0.1", 1) 15 | e.RcptTo = append(e.RcptTo, mail.Address{User: "test", Host: "grr.la"}) 16 | 17 | l, _ := log.GetLogger("./test_redis.log", "debug") 18 | g, err := New(BackendConfig{ 19 | "save_process": "Hasher|Redis", 20 | "redis_interface": "127.0.0.1:6379", 21 | "redis_expire_seconds": 7200, 22 | }, l) 23 | if err != nil { 24 | t.Error(err) 25 | return 26 | } 27 | err = g.Start() 28 | if err != nil { 29 | t.Error(err) 30 | return 31 | } 32 | defer func() { 33 | err := g.Shutdown() 34 | if err != nil { 35 | t.Error(err) 36 | } 37 | }() 38 | if gateway, ok := g.(*BackendGateway); ok { 39 | r := gateway.Process(e) 40 | if strings.Index(r.String(), "250 2.0.0 OK") == -1 { 41 | t.Error("redis processor didn't result with expected result, it said", r) 42 | } 43 | } 44 | // check the log 45 | if _, err := os.Stat("./test_redis.log"); err != nil { 46 | t.Error(err) 47 | return 48 | } 49 | if b, err := ioutil.ReadFile("./test_redis.log"); err != nil { 50 | t.Error(err) 51 | return 52 | } else { 53 | if strings.Index(string(b), "SETEX") == -1 { 54 | t.Error("Log did not contain SETEX, the log was: ", string(b)) 55 | } 56 | } 57 | 58 | if err := os.Remove("./test_redis.log"); err != nil { 59 | t.Error(err) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /backends/processor.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/mail" 5 | ) 6 | 7 | type SelectTask int 8 | 9 | const ( 10 | TaskSaveMail SelectTask = iota 11 | TaskValidateRcpt 12 | ) 13 | 14 | func (o SelectTask) String() string { 15 | switch o { 16 | case TaskSaveMail: 17 | return "save mail" 18 | case TaskValidateRcpt: 19 | return "validate recipient" 20 | } 21 | return "[unnamed task]" 22 | } 23 | 24 | var BackendResultOK = NewResult("200 OK") 25 | 26 | // Our processor is defined as something that processes the envelope and returns a result and error 27 | type Processor interface { 28 | Process(*mail.Envelope, SelectTask) (Result, error) 29 | } 30 | 31 | // Signature of Processor 32 | type ProcessWith func(*mail.Envelope, SelectTask) (Result, error) 33 | 34 | // Make ProcessWith will satisfy the Processor interface 35 | func (f ProcessWith) Process(e *mail.Envelope, task SelectTask) (Result, error) { 36 | // delegate to the anonymous function 37 | return f(e, task) 38 | } 39 | 40 | // DefaultProcessor is a undecorated worker that does nothing 41 | // Notice DefaultProcessor has no knowledge of the other decorators that have orthogonal concerns. 42 | type DefaultProcessor struct{} 43 | 44 | // do nothing except return the result 45 | // (this is the last call in the decorator stack, if it got here, then all is good) 46 | func (w DefaultProcessor) Process(e *mail.Envelope, task SelectTask) (Result, error) { 47 | return BackendResultOK, nil 48 | } 49 | 50 | // if no processors specified, skip operation 51 | type NoopProcessor struct{ DefaultProcessor } 52 | -------------------------------------------------------------------------------- /backends/util.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "crypto/md5" 7 | "fmt" 8 | "io" 9 | "net/textproto" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | // First capturing group is header name, second is header value. 15 | // Accounts for folding headers. 16 | var headerRegex, _ = regexp.Compile(`^([\S ]+):([\S ]+(?:\r\n\s[\S ]+)?)`) 17 | 18 | // ParseHeaders is deprecated, see mail.Envelope.ParseHeaders instead 19 | func ParseHeaders(mailData string) map[string]string { 20 | var headerSectionEnds int 21 | for i, char := range mailData[:len(mailData)-4] { 22 | if char == '\r' { 23 | if mailData[i+1] == '\n' && mailData[i+2] == '\r' && mailData[i+3] == '\n' { 24 | headerSectionEnds = i + 2 25 | } 26 | } 27 | } 28 | headers := make(map[string]string) 29 | matches := headerRegex.FindAllStringSubmatch(mailData[:headerSectionEnds], -1) 30 | for _, h := range matches { 31 | name := textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(strings.Replace(h[1], "\r\n", "", -1))) 32 | val := strings.TrimSpace(strings.Replace(h[2], "\r\n", "", -1)) 33 | headers[name] = val 34 | } 35 | return headers 36 | } 37 | 38 | // returns an md5 hash as string of hex characters 39 | func MD5Hex(stringArguments ...string) string { 40 | h := md5.New() 41 | var r *strings.Reader 42 | for i := 0; i < len(stringArguments); i++ { 43 | r = strings.NewReader(stringArguments[i]) 44 | _, _ = io.Copy(h, r) 45 | } 46 | sum := h.Sum([]byte{}) 47 | return fmt.Sprintf("%x", sum) 48 | } 49 | 50 | // concatenate & compress all strings passed in 51 | func Compress(stringArguments ...string) string { 52 | var b bytes.Buffer 53 | var r *strings.Reader 54 | w, _ := zlib.NewWriterLevel(&b, zlib.BestSpeed) 55 | for i := 0; i < len(stringArguments); i++ { 56 | r = strings.NewReader(stringArguments[i]) 57 | _, _ = io.Copy(w, r) 58 | } 59 | _ = w.Close() 60 | return b.String() 61 | } 62 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | ) 8 | 9 | var ( 10 | LineLimitExceeded = errors.New("maximum line length exceeded") 11 | MessageSizeExceeded = errors.New("maximum message size exceeded") 12 | ) 13 | 14 | // we need to adjust the limit, so we embed io.LimitedReader 15 | type adjustableLimitedReader struct { 16 | R *io.LimitedReader 17 | } 18 | 19 | // bolt this on so we can adjust the limit 20 | func (alr *adjustableLimitedReader) setLimit(n int64) { 21 | alr.R.N = n 22 | } 23 | 24 | // Returns a specific error when a limit is reached, that can be differentiated 25 | // from an EOF error from the standard io.Reader. 26 | func (alr *adjustableLimitedReader) Read(p []byte) (n int, err error) { 27 | n, err = alr.R.Read(p) 28 | if err == io.EOF && alr.R.N <= 0 { 29 | // return our custom error since io.Reader returns EOF 30 | err = LineLimitExceeded 31 | } 32 | return 33 | } 34 | 35 | // allocate a new adjustableLimitedReader 36 | func newAdjustableLimitedReader(r io.Reader, n int64) *adjustableLimitedReader { 37 | lr := &io.LimitedReader{R: r, N: n} 38 | return &adjustableLimitedReader{lr} 39 | } 40 | 41 | // This is a bufio.Reader what will use our adjustable limit reader 42 | // We 'extend' buffio to have the limited reader feature 43 | type smtpBufferedReader struct { 44 | *bufio.Reader 45 | alr *adjustableLimitedReader 46 | } 47 | 48 | // Delegate to the adjustable limited reader 49 | func (sbr *smtpBufferedReader) setLimit(n int64) { 50 | sbr.alr.setLimit(n) 51 | } 52 | 53 | // Set a new reader & use it to reset the underlying reader 54 | func (sbr *smtpBufferedReader) Reset(r io.Reader) { 55 | sbr.alr = newAdjustableLimitedReader(r, CommandLineMaxLength) 56 | sbr.Reader.Reset(sbr.alr) 57 | } 58 | 59 | // Allocate a new SMTPBufferedReader 60 | func newSMTPBufferedReader(rd io.Reader) *smtpBufferedReader { 61 | alr := newAdjustableLimitedReader(rd, CommandLineMaxLength) 62 | s := &smtpBufferedReader{bufio.NewReader(alr), alr} 63 | return s 64 | } 65 | -------------------------------------------------------------------------------- /backends/p_hasher.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "time" 9 | 10 | "github.com/flashmob/go-guerrilla/mail" 11 | ) 12 | 13 | // ---------------------------------------------------------------------------------- 14 | // Processor Name: hasher 15 | // ---------------------------------------------------------------------------------- 16 | // Description : Generates a unique md5 checksum id for an email 17 | // ---------------------------------------------------------------------------------- 18 | // Config Options: None 19 | // --------------:------------------------------------------------------------------- 20 | // Input : e.MailFrom, e.Subject, e.RcptTo 21 | // : assuming e.Subject was generated by "headersparser" processor 22 | // ---------------------------------------------------------------------------------- 23 | // Output : Checksum stored in e.Hash 24 | // ---------------------------------------------------------------------------------- 25 | func init() { 26 | processors["hasher"] = func() Decorator { 27 | return Hasher() 28 | } 29 | } 30 | 31 | // The hasher decorator computes a hash of the email for each recipient 32 | // It appends the hashes to envelope's Hashes slice. 33 | func Hasher() Decorator { 34 | return func(p Processor) Processor { 35 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 36 | 37 | if task == TaskSaveMail { 38 | // base hash, use subject from and timestamp-nano 39 | h := md5.New() 40 | ts := fmt.Sprintf("%d", time.Now().UnixNano()) 41 | _, _ = io.Copy(h, strings.NewReader(e.MailFrom.String())) 42 | _, _ = io.Copy(h, strings.NewReader(e.Subject)) 43 | _, _ = io.Copy(h, strings.NewReader(ts)) 44 | // using the base hash, calculate a unique hash for each recipient 45 | for i := range e.RcptTo { 46 | h2 := h 47 | _, _ = io.Copy(h2, strings.NewReader(e.RcptTo[i].String())) 48 | sum := h2.Sum([]byte{}) 49 | e.Hashes = append(e.Hashes, fmt.Sprintf("%x", sum)) 50 | } 51 | return p.Process(e, task) 52 | } else { 53 | return p.Process(e, task) 54 | } 55 | 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backends/p_debugger.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/mail" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // ---------------------------------------------------------------------------------- 10 | // Processor Name: debugger 11 | // ---------------------------------------------------------------------------------- 12 | // Description : Log received emails 13 | // ---------------------------------------------------------------------------------- 14 | // Config Options: log_received_mails bool - log if true 15 | // --------------:------------------------------------------------------------------- 16 | // Input : e.MailFrom, e.RcptTo, e.Header 17 | // ---------------------------------------------------------------------------------- 18 | // Output : none (only output to the log if enabled) 19 | // ---------------------------------------------------------------------------------- 20 | func init() { 21 | processors[strings.ToLower(defaultProcessor)] = func() Decorator { 22 | return Debugger() 23 | } 24 | } 25 | 26 | type debuggerConfig struct { 27 | LogReceivedMails bool `json:"log_received_mails"` 28 | SleepSec int `json:"sleep_seconds,omitempty"` 29 | } 30 | 31 | func Debugger() Decorator { 32 | var config *debuggerConfig 33 | initFunc := InitializeWith(func(backendConfig BackendConfig) error { 34 | configType := BaseConfig(&debuggerConfig{}) 35 | bcfg, err := Svc.ExtractConfig(backendConfig, configType) 36 | if err != nil { 37 | return err 38 | } 39 | config = bcfg.(*debuggerConfig) 40 | return nil 41 | }) 42 | Svc.AddInitializer(initFunc) 43 | return func(p Processor) Processor { 44 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 45 | if task == TaskSaveMail { 46 | if config.LogReceivedMails { 47 | Log().Infof("Mail from: %s / to: %v", e.MailFrom.String(), e.RcptTo) 48 | Log().Info("Headers are:", e.Header) 49 | } 50 | 51 | if config.SleepSec > 0 { 52 | Log().Infof("sleeping for %d", config.SleepSec) 53 | time.Sleep(time.Second * time.Duration(config.SleepSec)) 54 | Log().Infof("woke up") 55 | 56 | if config.SleepSec == 1 { 57 | panic("panic on purpose") 58 | } 59 | 60 | } 61 | 62 | // continue to the next Processor in the decorator stack 63 | return p.Process(e, task) 64 | } else { 65 | return p.Process(e, task) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import ( 4 | evbus "github.com/asaskevich/EventBus" 5 | ) 6 | 7 | type Event int 8 | 9 | const ( 10 | // when a new config was loaded 11 | EventConfigNewConfig Event = iota 12 | // when allowed_hosts changed 13 | EventConfigAllowedHosts 14 | // when pid_file changed 15 | EventConfigPidFile 16 | // when log_file changed 17 | EventConfigLogFile 18 | // when it's time to reload the main log file 19 | EventConfigLogReopen 20 | // when log level changed 21 | EventConfigLogLevel 22 | // when the backend's config changed 23 | EventConfigBackendConfig 24 | // when a new server was added 25 | EventConfigServerNew 26 | // when an existing server was removed 27 | EventConfigServerRemove 28 | // when a new server config was detected (general event) 29 | EventConfigServerConfig 30 | // when a server was enabled 31 | EventConfigServerStart 32 | // when a server was disabled 33 | EventConfigServerStop 34 | // when a server's log file changed 35 | EventConfigServerLogFile 36 | // when it's time to reload the server's log 37 | EventConfigServerLogReopen 38 | // when a server's timeout changed 39 | EventConfigServerTimeout 40 | // when a server's max clients changed 41 | EventConfigServerMaxClients 42 | // when a server's TLS config changed 43 | EventConfigServerTLSConfig 44 | ) 45 | 46 | var eventList = [...]string{ 47 | "config_change:new_config", 48 | "config_change:allowed_hosts", 49 | "config_change:pid_file", 50 | "config_change:log_file", 51 | "config_change:reopen_log_file", 52 | "config_change:log_level", 53 | "config_change:backend_config", 54 | "server_change:new_server", 55 | "server_change:remove_server", 56 | "server_change:update_config", 57 | "server_change:start_server", 58 | "server_change:stop_server", 59 | "server_change:new_log_file", 60 | "server_change:reopen_log_file", 61 | "server_change:timeout", 62 | "server_change:max_clients", 63 | "server_change:tls_config", 64 | } 65 | 66 | func (e Event) String() string { 67 | return eventList[e] 68 | } 69 | 70 | type EventHandler struct { 71 | evbus.Bus 72 | } 73 | 74 | func (h *EventHandler) Subscribe(topic Event, fn interface{}) error { 75 | if h.Bus == nil { 76 | h.Bus = evbus.New() 77 | } 78 | return h.Bus.Subscribe(topic.String(), fn) 79 | } 80 | 81 | func (h *EventHandler) Publish(topic Event, args ...interface{}) { 82 | h.Bus.Publish(topic.String(), args...) 83 | } 84 | 85 | func (h *EventHandler) Unsubscribe(topic Event, handler interface{}) error { 86 | return h.Bus.Unsubscribe(topic.String(), handler) 87 | } 88 | -------------------------------------------------------------------------------- /backends/p_sql_test.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "database/sql" 5 | "flag" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/flashmob/go-guerrilla/log" 13 | "github.com/flashmob/go-guerrilla/mail" 14 | 15 | _ "github.com/go-sql-driver/mysql" 16 | ) 17 | 18 | var ( 19 | mailTableFlag = flag.String("mail-table", "test", "Table to use for testing the SQL backend") 20 | sqlDSNFlag = flag.String("sql-dsn", "", "DSN to use for testing the SQL backend") 21 | sqlDriverFlag = flag.String("sql-driver", "mysql", "Driver to use for testing the SQL backend") 22 | ) 23 | 24 | func TestSQL(t *testing.T) { 25 | if *sqlDSNFlag == "" { 26 | t.Skip("requires -sql-dsn to run") 27 | } 28 | 29 | logger, err := log.GetLogger(log.OutputOff.String(), log.DebugLevel.String()) 30 | if err != nil { 31 | t.Fatal("get logger:", err) 32 | } 33 | 34 | cfg := BackendConfig{ 35 | "save_process": "sql", 36 | "mail_table": *mailTableFlag, 37 | "primary_mail_host": "example.com", 38 | "sql_driver": *sqlDriverFlag, 39 | "sql_dsn": *sqlDSNFlag, 40 | } 41 | backend, err := New(cfg, logger) 42 | if err != nil { 43 | t.Fatal("new backend:", err) 44 | } 45 | if err := backend.Start(); err != nil { 46 | t.Fatal("start backend: ", err) 47 | } 48 | 49 | hash := strconv.FormatInt(time.Now().UnixNano(), 10) 50 | envelope := &mail.Envelope{ 51 | RcptTo: []mail.Address{{User: "user", Host: "example.com"}}, 52 | Hashes: []string{hash}, 53 | } 54 | 55 | // The SQL processor is expected to use the hash to queue the mail. 56 | result := backend.Process(envelope) 57 | if !strings.Contains(result.String(), hash) { 58 | t.Errorf("expected message to be queued with hash, got %q", result) 59 | } 60 | 61 | // Ensure that a record actually exists. 62 | results, err := findRows(hash) 63 | if err != nil { 64 | t.Fatal("find rows: ", err) 65 | } 66 | if len(results) != 1 { 67 | t.Fatalf("expected one row, got %d", len(results)) 68 | } 69 | } 70 | 71 | func findRows(hash string) ([]string, error) { 72 | db, err := sql.Open(*sqlDriverFlag, *sqlDSNFlag) 73 | if err != nil { 74 | return nil, err 75 | } 76 | defer func() { 77 | _ = db.Close() 78 | }() 79 | 80 | stmt := fmt.Sprintf(`SELECT hash FROM %s WHERE hash = ?`, *mailTableFlag) 81 | rows, err := db.Query(stmt, hash) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | var results []string 87 | for rows.Next() { 88 | var result string 89 | if err := rows.Scan(&result); err != nil { 90 | return nil, err 91 | } 92 | results = append(results, result) 93 | } 94 | return results, nil 95 | } 96 | -------------------------------------------------------------------------------- /mocks/conn_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "time" 7 | ) 8 | 9 | // Mocks a net.Conn - server and client sides. 10 | // See server_test.go for usage examples 11 | // Taken from https://github.com/jordwest/mock-conn 12 | // This great answer http://stackoverflow.com/questions/1976950/simulate-a-tcp-connection-in-go 13 | 14 | // Addr is a fake network interface which implements the net.Addr interface 15 | type Addr struct { 16 | NetworkString string 17 | AddrString string 18 | } 19 | 20 | func (a Addr) Network() string { 21 | return a.NetworkString 22 | } 23 | 24 | func (a Addr) String() string { 25 | return a.AddrString 26 | } 27 | 28 | // End is one 'end' of a simulated connection. 29 | type End struct { 30 | Reader *io.PipeReader 31 | Writer *io.PipeWriter 32 | } 33 | 34 | func (e End) Close() error { 35 | if err := e.Writer.Close(); err != nil { 36 | return err 37 | } 38 | if err := e.Reader.Close(); err != nil { 39 | return err 40 | } 41 | return nil 42 | } 43 | 44 | func (e End) Read(data []byte) (n int, err error) { return e.Reader.Read(data) } 45 | func (e End) Write(data []byte) (n int, err error) { return e.Writer.Write(data) } 46 | 47 | func (e End) LocalAddr() net.Addr { 48 | return Addr{ 49 | NetworkString: "tcp", 50 | AddrString: "127.0.0.1", 51 | } 52 | } 53 | 54 | func (e End) RemoteAddr() net.Addr { 55 | return Addr{ 56 | NetworkString: "tcp", 57 | AddrString: "127.0.0.1", 58 | } 59 | } 60 | 61 | func (e End) SetDeadline(t time.Time) error { return nil } 62 | func (e End) SetReadDeadline(t time.Time) error { return nil } 63 | func (e End) SetWriteDeadline(t time.Time) error { return nil } 64 | 65 | // MockConn facilitates testing by providing two connected ReadWriteClosers 66 | // each of which can be used in place of a net.Conn 67 | type Conn struct { 68 | Server *End 69 | Client *End 70 | } 71 | 72 | func NewConn() *Conn { 73 | // A connection consists of two pipes: 74 | // Client | Server 75 | // writes ===> reads 76 | // reads <=== writes 77 | 78 | serverRead, clientWrite := io.Pipe() 79 | clientRead, serverWrite := io.Pipe() 80 | 81 | return &Conn{ 82 | Server: &End{ 83 | Reader: serverRead, 84 | Writer: serverWrite, 85 | }, 86 | Client: &End{ 87 | Reader: clientRead, 88 | Writer: clientWrite, 89 | }, 90 | } 91 | } 92 | 93 | func (c *Conn) Close() error { 94 | if err := c.Server.Close(); err != nil { 95 | return err 96 | } 97 | if err := c.Client.Close(); err != nil { 98 | return err 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /backends/p_header.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/mail" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type HeaderConfig struct { 10 | PrimaryHost string `json:"primary_mail_host"` 11 | } 12 | 13 | // ---------------------------------------------------------------------------------- 14 | // Processor Name: header 15 | // ---------------------------------------------------------------------------------- 16 | // Description : Adds delivery information headers to e.DeliveryHeader 17 | // ---------------------------------------------------------------------------------- 18 | // Config Options: none 19 | // --------------:------------------------------------------------------------------- 20 | // Input : e.Helo 21 | // : e.RemoteAddress 22 | // : e.RcptTo 23 | // : e.Hashes 24 | // ---------------------------------------------------------------------------------- 25 | // Output : Sets e.DeliveryHeader with additional delivery info 26 | // ---------------------------------------------------------------------------------- 27 | func init() { 28 | processors["header"] = func() Decorator { 29 | return Header() 30 | } 31 | } 32 | 33 | // Generate the MTA delivery header 34 | // Sets e.DeliveryHeader part of the envelope with the generated header 35 | func Header() Decorator { 36 | 37 | var config *HeaderConfig 38 | 39 | Svc.AddInitializer(InitializeWith(func(backendConfig BackendConfig) error { 40 | configType := BaseConfig(&HeaderConfig{}) 41 | bcfg, err := Svc.ExtractConfig(backendConfig, configType) 42 | if err != nil { 43 | return err 44 | } 45 | config = bcfg.(*HeaderConfig) 46 | return nil 47 | })) 48 | 49 | return func(p Processor) Processor { 50 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 51 | if task == TaskSaveMail { 52 | to := strings.TrimSpace(e.RcptTo[0].User) + "@" + config.PrimaryHost 53 | hash := "unknown" 54 | if len(e.Hashes) > 0 { 55 | hash = e.Hashes[0] 56 | } 57 | protocol := "SMTP" 58 | if e.ESMTP { 59 | protocol = "E" + protocol 60 | } 61 | if e.TLS { 62 | protocol = protocol + "S" 63 | } 64 | var addHead string 65 | addHead += "Delivered-To: " + to + "\n" 66 | addHead += "Received: from " + e.RemoteIP + " ([" + e.RemoteIP + "])\n" 67 | if len(e.RcptTo) > 0 { 68 | addHead += " by " + e.RcptTo[0].Host + " with " + protocol + " id " + hash + "@" + e.RcptTo[0].Host + ";\n" 69 | } 70 | addHead += " " + time.Now().Format(time.RFC1123Z) + "\n" 71 | // save the result 72 | e.DeliveryHeader = addHead 73 | // next processor 74 | return p.Process(e, task) 75 | 76 | } else { 77 | return p.Process(e, task) 78 | } 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /mail/rfc5321/address_test.go: -------------------------------------------------------------------------------- 1 | package rfc5321 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseRFC5322(t *testing.T) { 8 | var s RFC5322 9 | if _, err := s.Address([]byte("\"Mike Jones\" ")); err != nil { 10 | t.Error(err) 11 | } 12 | // parse a simple address 13 | if a, err := s.Address([]byte("test@tdomain.com")); err != nil { 14 | t.Error(err) 15 | } else { 16 | if len(a.List) != 1 { 17 | t.Error("expecting 1 address") 18 | } else { 19 | // display name should be empty 20 | } 21 | } 22 | } 23 | 24 | func TestParseRFC5322Decoder(t *testing.T) { 25 | var s RFC5322 26 | if _, err := s.Address([]byte("=?ISO-8859-1?Q?Andr=E9?= =?ISO-8859-1?Q?Andr=E9?= ")); err != nil { 27 | t.Error(err) 28 | } 29 | } 30 | 31 | func TestParseRFC5322IP(t *testing.T) { 32 | var s RFC5322 33 | // this is an incorrect IPv6 address 34 | if _, err := s.Address([]byte("\"Mike Jones\" <\"testing 123\"@[IPv6:IPv6:2001:db8::1]>")); err == nil { 35 | t.Error("Expecting error, because Ip address was wrong") 36 | } 37 | // this one is correct, with quoted display name and quoted local-part 38 | if a, err := s.Address([]byte("\"Mike Jones\" <\"testing 123\"@[IPv6:2001:db8::1]>")); err != nil { 39 | t.Error(err) 40 | } else { 41 | if len(a.List) != 1 { 42 | t.Error("expecting 1 address, but got", len(a.List)) 43 | } else { 44 | if a.List[0].DisplayNameQuoted == false { 45 | t.Error(".List[0].DisplayNameQuoted is false, expecting true") 46 | } 47 | if a.List[0].LocalPartQuoted == false { 48 | t.Error(".List[0].LocalPartQuotes is false, expecting true") 49 | } 50 | if a.List[0].IP == nil { 51 | t.Error("a.List[0].IP should not be nil") 52 | } 53 | if a.List[0].Domain != "2001:db8::1" { 54 | t.Error("a.List[0].Domain should be, but got", a.List[0].Domain) 55 | } 56 | } 57 | } 58 | } 59 | 60 | func TestParseRFC5322Group(t *testing.T) { 61 | // A Group:Ed Jones ,joe@where.test,John ; 62 | var s RFC5322 63 | if a, err := s.Address([]byte("A Group:Ed Jones ,joe@where.test,John , \"te \\\" st\" ;")); err != nil { 64 | t.Error(err) 65 | } else { 66 | if a.Group != "A Group" { 67 | t.Error("expecting a.Group to be \"A Group\" but got:", a.Group) 68 | } 69 | if len(a.List) != 4 { 70 | t.Error("expecting 4 addresses, but got", len(a.List)) 71 | } else { 72 | if a.List[0].DisplayName != "Ed Jones" { 73 | t.Error("expecting a.List[0].DisplayName 'Ed Jones' but got:", a.List[0].DisplayName) 74 | } 75 | if a.List[0].LocalPart != "c" { 76 | t.Error("expecting a.List[0].LocalPart 'c' but got:", a.List[0].LocalPart) 77 | } 78 | if a.List[0].Domain != "a.test" { 79 | t.Error("expecting a.List[0].Domain 'a.test' but got:", a.List[0].Domain) 80 | } 81 | } 82 | 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /goguerrilla.conf.sample: -------------------------------------------------------------------------------- 1 | { 2 | "log_file" : "stderr", 3 | "log_level" : "info", 4 | "allowed_hosts": [ 5 | "guerrillamail.com", 6 | "guerrillamailblock.com", 7 | "sharklasers.com", 8 | "guerrillamail.net", 9 | "guerrillamail.org" 10 | ], 11 | "pid_file" : "/var/run/go-guerrilla.pid", 12 | "backend_config": { 13 | "log_received_mails": true, 14 | "save_workers_size": 1, 15 | "save_process" : "HeadersParser|Header|Debugger", 16 | "primary_mail_host" : "mail.example.com", 17 | "gw_save_timeout" : "30s", 18 | "gw_val_rcpt_timeout" : "3s" 19 | }, 20 | "servers" : [ 21 | { 22 | "is_enabled" : true, 23 | "host_name":"mail.test.com", 24 | "max_size": 1000000, 25 | "timeout":180, 26 | "listen_interface":"127.0.0.1:25", 27 | "max_clients": 1000, 28 | "log_file" : "stderr", 29 | "tls" : { 30 | "start_tls_on":true, 31 | "tls_always_on":false, 32 | "private_key_file":"/path/to/pem/file/test.com.key", 33 | "public_key_file":"/path/to/pem/file/test.com.crt", 34 | "protocols" : ["tls1.0", "tls1.2"], 35 | "ciphers" : ["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", "TLS_RSA_WITH_RC4_128_SHA", "TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", "TLS_ECDHE_RSA_WITH_RC4_128_SHA", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"], 36 | "curves" : ["P256", "P384", "P521", "X25519"], 37 | "client_auth_type" : "NoClientCert" 38 | } 39 | }, 40 | { 41 | "is_enabled" : false, 42 | "host_name":"mail.test.com", 43 | "max_size":1000000, 44 | "timeout":180, 45 | "listen_interface":"127.0.0.1:465", 46 | "max_clients":500, 47 | "log_file" : "stderr", 48 | "tls" : { 49 | "private_key_file":"/path/to/pem/file/test.com.key", 50 | "public_key_file":"/path/to/pem/file/test.com.crt", 51 | "start_tls_on":false, 52 | "tls_always_on":true, 53 | "protocols" : ["tls1.0", "tls1.2"], 54 | "ciphers" : ["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", "TLS_RSA_WITH_RC4_128_SHA", "TLS_RSA_WITH_AES_128_GCM_SHA256", "TLS_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", "TLS_ECDHE_RSA_WITH_RC4_128_SHA", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"], 55 | "curves" : ["P256", "P384", "P521", "X25519"], 56 | "client_auth_type" : "NoClientCert" 57 | } 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /backends/gateway_test.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "fmt" 5 | "github.com/flashmob/go-guerrilla/log" 6 | "github.com/flashmob/go-guerrilla/mail" 7 | "strings" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestStates(t *testing.T) { 13 | gw := BackendGateway{} 14 | str := fmt.Sprintf("%s", gw.State) 15 | if strings.Index(str, "NewState") != 0 { 16 | t.Error("Backend should begin in NewState") 17 | } 18 | } 19 | 20 | func TestInitialize(t *testing.T) { 21 | c := BackendConfig{ 22 | "save_process": "HeadersParser|Debugger", 23 | "log_received_mails": true, 24 | "save_workers_size": "1", 25 | } 26 | 27 | gateway := &BackendGateway{} 28 | err := gateway.Initialize(c) 29 | if err != nil { 30 | t.Error("Gateway did not init because:", err) 31 | t.Fail() 32 | } 33 | if gateway.processors == nil { 34 | t.Error("gateway.chains should not be nil") 35 | } else if len(gateway.processors) != 1 { 36 | t.Error("len(gateway.chains) should be 1, but got", len(gateway.processors)) 37 | } 38 | 39 | if gateway.conveyor == nil { 40 | t.Error("gateway.conveyor should not be nil") 41 | } else if cap(gateway.conveyor) != gateway.workersSize() { 42 | t.Error("gateway.conveyor channel buffer cap does not match worker size, cap was", cap(gateway.conveyor)) 43 | } 44 | 45 | if gateway.State != BackendStateInitialized { 46 | t.Error("gateway.State is not in initialized state, got ", gateway.State) 47 | } 48 | 49 | } 50 | 51 | func TestStartProcessStop(t *testing.T) { 52 | c := BackendConfig{ 53 | "save_process": "HeadersParser|Debugger", 54 | "log_received_mails": true, 55 | "save_workers_size": 2, 56 | } 57 | 58 | gateway := &BackendGateway{} 59 | err := gateway.Initialize(c) 60 | 61 | mainlog, _ := log.GetLogger(log.OutputOff.String(), "debug") 62 | Svc.SetMainlog(mainlog) 63 | 64 | if err != nil { 65 | t.Error("Gateway did not init because:", err) 66 | t.Fail() 67 | } 68 | err = gateway.Start() 69 | if err != nil { 70 | t.Error("Gateway did not start because:", err) 71 | t.Fail() 72 | } 73 | if gateway.State != BackendStateRunning { 74 | t.Error("gateway.State is not in rinning state, got ", gateway.State) 75 | } 76 | // can we place an envelope on the conveyor channel? 77 | 78 | e := &mail.Envelope{ 79 | RemoteIP: "127.0.0.1", 80 | QueuedId: "abc12345", 81 | Helo: "helo.example.com", 82 | MailFrom: mail.Address{User: "test", Host: "example.com"}, 83 | TLS: true, 84 | } 85 | e.PushRcpt(mail.Address{User: "test", Host: "example.com"}) 86 | e.Data.WriteString("Subject:Test\n\nThis is a test.") 87 | notify := make(chan *notifyMsg) 88 | 89 | gateway.conveyor <- &workerMsg{e, notify, TaskSaveMail} 90 | 91 | // it should not produce any errors 92 | // headers (subject) should be parsed. 93 | 94 | select { 95 | case status := <-notify: 96 | 97 | if status.err != nil { 98 | t.Error("envelope processing failed with:", status.err) 99 | } 100 | if e.Header["Subject"][0] != "Test" { 101 | t.Error("envelope processing did not parse header") 102 | } 103 | 104 | case <-time.After(time.Second): 105 | t.Error("gateway did not respond after 1 second") 106 | t.Fail() 107 | } 108 | 109 | err = gateway.Shutdown() 110 | if err != nil { 111 | t.Error("Gateway did not shutdown") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /mail/encoding/encoding_test.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/mail" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // This will use the golang.org/x/net/html/charset encoder 10 | func TestEncodingMimeHeaderDecode(t *testing.T) { 11 | str := mail.MimeHeaderDecode("=?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?=") 12 | if i := strings.Index(str, "【女子高生チャ"); i != 0 { 13 | t.Error("expecting 【女子高生チャ, got:", str) 14 | } 15 | str = mail.MimeHeaderDecode("=?ISO-8859-1?Q?Andr=E9?= Pirard ") 16 | if strings.Index(str, "André Pirard") != 0 { 17 | t.Error("expecting André Pirard, got:", str) 18 | 19 | } 20 | 21 | str = mail.MimeHeaderDecode("=?ISO-8859-1?Q?Andr=E9?=\tPirard ") 22 | if strings.Index(str, "André\tPirard") != 0 { 23 | t.Error("expecting André Pirard, got:", str) 24 | 25 | } 26 | 27 | } 28 | 29 | // TestEncodingMimeHeaderDecodeEnding tests when the encoded word is at the end 30 | func TestEncodingMimeHeaderDecodeEnding(t *testing.T) { 31 | 32 | // plaintext at the beginning 33 | str := mail.MimeHeaderDecode("What about this one? =?ISO-8859-1?Q?Andr=E9?=") 34 | if str != "What about this one? André" { 35 | t.Error("expecting: What about this one? André, but got:", str) 36 | 37 | } 38 | 39 | // not plaintext at beginning 40 | str = mail.MimeHeaderDecode("=?ISO-8859-1?Q?Andr=E9?= What about this one? =?ISO-8859-1?Q?Andr=E9?=") 41 | if str != "André What about this one? André" { 42 | t.Error("expecting: André What about this one? André, but got:", str) 43 | 44 | } 45 | // plaintext at beginning corruped 46 | str = mail.MimeHeaderDecode("=?ISO-8859-1?B?Andr=E9?= What about this one? =?ISO-8859-1?Q?Andr=E9?=") 47 | if strings.Index(str, "=?ISO-8859-1?B?Andr=E9?= What about this one? André") != 0 { 48 | t.Error("expecting:=?ISO-8859-1?B?Andr=E9?= What about this one? André, but got:", str) 49 | 50 | } 51 | } 52 | 53 | // TestEncodingMimeHeaderDecodeBad tests the case of a malformed encoding 54 | func TestEncodingMimeHeaderDecodeBad(t *testing.T) { 55 | // bad base64 encoding, it should return the string unencoded 56 | str := mail.MimeHeaderDecode("=?ISO-8859-1?B?Andr=E9?=\tPirard ") 57 | if strings.Index(str, "=?ISO-8859-1?B?Andr=E9?=\tPirard ") != 0 { 58 | t.Error("expecting =?ISO-8859-1?B?Andr=E9?=\tPirard , got:", str) 59 | 60 | } 61 | 62 | } 63 | 64 | func TestEncodingMimeHeaderDecodeNoSpace(t *testing.T) { 65 | // there is no space 66 | str := mail.MimeHeaderDecode("A =?ISO-8859-1?Q?Andr=E9?=WORLD IN YOUR POCKET") 67 | if str != "A AndréWORLD IN YOUR POCKET" { 68 | // in this case, if it's QP and ?= is found at the end then we can assume no space? 69 | t.Error("Did not get [A AndréWORLD IN YOUR POCKET]") 70 | } 71 | } 72 | 73 | func TestEncodingMimeHeaderDecodeMulti(t *testing.T) { 74 | 75 | str := mail.MimeHeaderDecode("=?iso-2022-jp?B?GyRCIVpLXEZ8Om89fCFbPEIkT0lUOk5NUSROJU0lPyROSn0bKEI=?= =?iso-2022-jp?B?GyRCJCxCPyQkJEckORsoQg==?=") 76 | if strings.Index(str, "【本日削除】実は不採用のネタの方が多いです") != 0 { 77 | t.Error("expecting 【本日削除】実は不採用のネタの方が多いです, got:", str) 78 | } 79 | 80 | str = mail.MimeHeaderDecode("=?iso-2022-jp?B?GyRCIVpLXEZ8Om89fCFbPEIkT0lUOk5NUSROJU0lPyROSn0bKEI=?= \t =?iso-2022-jp?B?GyRCJCxCPyQkJEckORsoQg==?=") 81 | if strings.Index(str, "【本日削除】実は不採用のネタの方が多いです") != 0 { 82 | t.Error("expecting 【本日削除】実は不採用のネタの方が多いです, got:", str) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /backends/p_compressor.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "github.com/flashmob/go-guerrilla/mail" 7 | "io" 8 | "sync" 9 | ) 10 | 11 | // ---------------------------------------------------------------------------------- 12 | // Processor Name: compressor 13 | // ---------------------------------------------------------------------------------- 14 | // Description : Compress the e.Data (email data) and e.DeliveryHeader together 15 | // ---------------------------------------------------------------------------------- 16 | // Config Options: None 17 | // --------------:------------------------------------------------------------------- 18 | // Input : e.Data, e.DeliveryHeader generated by Header() processor 19 | // ---------------------------------------------------------------------------------- 20 | // Output : sets the pointer to a compressor in e.Info["zlib-compressor"] 21 | // : to write the compressed data, simply use fmt to print as a string, 22 | // : eg. fmt.Println("%s", e.Info["zlib-compressor"]) 23 | // : or just call the String() func .Info["zlib-compressor"].String() 24 | // : Note that it can only be outputted once. It destroys the buffer 25 | // : after being printed 26 | // ---------------------------------------------------------------------------------- 27 | func init() { 28 | processors["compressor"] = func() Decorator { 29 | return Compressor() 30 | } 31 | } 32 | 33 | // compressedData struct will be compressed using zlib when printed via fmt 34 | type DataCompressor struct { 35 | ExtraHeaders []byte 36 | Data *bytes.Buffer 37 | // the pool is used to recycle buffers to ease up on the garbage collector 38 | Pool *sync.Pool 39 | } 40 | 41 | // newCompressedData returns a new CompressedData 42 | func newCompressor() *DataCompressor { 43 | // grab it from the pool 44 | var p = sync.Pool{ 45 | // if not available, then create a new one 46 | New: func() interface{} { 47 | var b bytes.Buffer 48 | return &b 49 | }, 50 | } 51 | return &DataCompressor{ 52 | Pool: &p, 53 | } 54 | } 55 | 56 | // Set the extraheaders and buffer of data to compress 57 | func (c *DataCompressor) set(b []byte, d *bytes.Buffer) { 58 | c.ExtraHeaders = b 59 | c.Data = d 60 | } 61 | 62 | // String implements the Stringer interface. 63 | // Can only be called once! 64 | // This is because the compression buffer will be reset and compressor will be returned to the pool 65 | func (c *DataCompressor) String() string { 66 | if c.Data == nil { 67 | return "" 68 | } 69 | //borrow a buffer form the pool 70 | b := c.Pool.Get().(*bytes.Buffer) 71 | // put back in the pool 72 | defer func() { 73 | b.Reset() 74 | c.Pool.Put(b) 75 | }() 76 | 77 | var r *bytes.Reader 78 | w, _ := zlib.NewWriterLevel(b, zlib.BestSpeed) 79 | r = bytes.NewReader(c.ExtraHeaders) 80 | _, _ = io.Copy(w, r) 81 | _, _ = io.Copy(w, c.Data) 82 | _ = w.Close() 83 | return b.String() 84 | } 85 | 86 | // clear it, without clearing the pool 87 | func (c *DataCompressor) clear() { 88 | c.ExtraHeaders = []byte{} 89 | c.Data = nil 90 | } 91 | 92 | func Compressor() Decorator { 93 | return func(p Processor) Processor { 94 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 95 | if task == TaskSaveMail { 96 | compressor := newCompressor() 97 | compressor.set([]byte(e.DeliveryHeader), &e.Data) 98 | // put the pointer in there for other processors to use later in the line 99 | e.Values["zlib-compressor"] = compressor 100 | // continue to the next Processor in the decorator stack 101 | return p.Process(e, task) 102 | } else { 103 | return p.Process(e, task) 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /backends/p_redis.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/flashmob/go-guerrilla/mail" 7 | "github.com/flashmob/go-guerrilla/response" 8 | ) 9 | 10 | // ---------------------------------------------------------------------------------- 11 | // Processor Name: redis 12 | // ---------------------------------------------------------------------------------- 13 | // Description : Saves the e.Data (email data) and e.DeliveryHeader together in redis 14 | // : using the hash generated by the "hash" processor and stored in 15 | // : e.Hashes 16 | // ---------------------------------------------------------------------------------- 17 | // Config Options: redis_expire_seconds int - how many seconds to expiry 18 | // : redis_interface string - : eg, 127.0.0.1:6379 19 | // --------------:------------------------------------------------------------------- 20 | // Input : e.Data 21 | // : e.DeliveryHeader generated by Header() processor 22 | // : 23 | // ---------------------------------------------------------------------------------- 24 | // Output : Sets e.QueuedId with the first item fromHashes[0] 25 | // ---------------------------------------------------------------------------------- 26 | func init() { 27 | 28 | processors["redis"] = func() Decorator { 29 | return Redis() 30 | } 31 | } 32 | 33 | type RedisProcessorConfig struct { 34 | RedisExpireSeconds int `json:"redis_expire_seconds"` 35 | RedisInterface string `json:"redis_interface"` 36 | } 37 | 38 | type RedisProcessor struct { 39 | isConnected bool 40 | conn RedisConn 41 | } 42 | 43 | func (r *RedisProcessor) redisConnection(redisInterface string) (err error) { 44 | if r.isConnected == false { 45 | r.conn, err = RedisDialer("tcp", redisInterface) 46 | if err != nil { 47 | // handle error 48 | return err 49 | } 50 | r.isConnected = true 51 | } 52 | return nil 53 | } 54 | 55 | // The redis decorator stores the email data in redis 56 | 57 | func Redis() Decorator { 58 | 59 | var config *RedisProcessorConfig 60 | redisClient := &RedisProcessor{} 61 | // read the config into RedisProcessorConfig 62 | Svc.AddInitializer(InitializeWith(func(backendConfig BackendConfig) error { 63 | configType := BaseConfig(&RedisProcessorConfig{}) 64 | bcfg, err := Svc.ExtractConfig(backendConfig, configType) 65 | if err != nil { 66 | return err 67 | } 68 | config = bcfg.(*RedisProcessorConfig) 69 | if redisErr := redisClient.redisConnection(config.RedisInterface); redisErr != nil { 70 | err := fmt.Errorf("redis cannot connect, check your settings: %s", redisErr) 71 | return err 72 | } 73 | return nil 74 | })) 75 | // When shutting down 76 | Svc.AddShutdowner(ShutdownWith(func() error { 77 | if redisClient.isConnected { 78 | return redisClient.conn.Close() 79 | } 80 | return nil 81 | })) 82 | 83 | var redisErr error 84 | 85 | return func(p Processor) Processor { 86 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 87 | 88 | if task == TaskSaveMail { 89 | hash := "" 90 | if len(e.Hashes) > 0 { 91 | e.QueuedId = e.Hashes[0] 92 | hash = e.Hashes[0] 93 | var stringer fmt.Stringer 94 | // a compressor was set 95 | if c, ok := e.Values["zlib-compressor"]; ok { 96 | stringer = c.(*DataCompressor) 97 | } else { 98 | stringer = e 99 | } 100 | redisErr = redisClient.redisConnection(config.RedisInterface) 101 | if redisErr != nil { 102 | Log().WithError(redisErr).Warn("Error while connecting to redis") 103 | result := NewResult(response.Canned.FailBackendTransaction) 104 | return result, redisErr 105 | } 106 | _, doErr := redisClient.conn.Do("SETEX", hash, config.RedisExpireSeconds, stringer) 107 | if doErr != nil { 108 | Log().WithError(doErr).Warn("Error while SETEX to redis") 109 | result := NewResult(response.Canned.FailBackendTransaction) 110 | return result, doErr 111 | } 112 | e.Values["redis"] = "redis" // the next processor will know to look in redis for the message data 113 | } else { 114 | Log().Error("Redis needs a Hasher() process before it") 115 | result := NewResult(response.Canned.FailBackendTransaction) 116 | return result, StorageError 117 | } 118 | 119 | return p.Process(e, task) 120 | } else { 121 | // nothing to do for this task 122 | return p.Process(e, task) 123 | } 124 | 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:0a2a75a7b0d611bf7ecb4e5a0054242815dc27e857b4b9f8ec62225993fd11b7" 6 | name = "github.com/asaskevich/EventBus" 7 | packages = ["."] 8 | pruneopts = "UT" 9 | revision = "68a521d7cbbb7a859c2608b06342f384b3bd5f5a" 10 | 11 | [[projects]] 12 | digest = "1:ec6f9bf5e274c833c911923c9193867f3f18788c461f76f05f62bb1510e0ae65" 13 | name = "github.com/go-sql-driver/mysql" 14 | packages = ["."] 15 | pruneopts = "UT" 16 | revision = "72cd26f257d44c1114970e19afddcd812016007e" 17 | version = "v1.4.1" 18 | 19 | [[projects]] 20 | digest = "1:38ec74012390146c45af1f92d46e5382b50531247929ff3a685d2b2be65155ac" 21 | name = "github.com/gomodule/redigo" 22 | packages = [ 23 | "internal", 24 | "redis" 25 | ] 26 | pruneopts = "UT" 27 | revision = "9c11da706d9b7902c6da69c592f75637793fe121" 28 | version = "v2.0.0" 29 | 30 | [[projects]] 31 | digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" 32 | name = "github.com/inconshreveable/mousetrap" 33 | packages = ["."] 34 | pruneopts = "UT" 35 | revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" 36 | version = "v1.0" 37 | 38 | [[projects]] 39 | digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" 40 | name = "github.com/konsorten/go-windows-terminal-sequences" 41 | packages = ["."] 42 | pruneopts = "UT" 43 | revision = "f55edac94c9bbba5d6182a4be46d86a2c9b5b50e" 44 | version = "v1.0.2" 45 | 46 | [[projects]] 47 | digest = "1:04457f9f6f3ffc5fea48e71d62f2ca256637dee0a04d710288e27e05c8b41976" 48 | name = "github.com/sirupsen/logrus" 49 | packages = ["."] 50 | pruneopts = "UT" 51 | revision = "839c75faf7f98a33d445d181f3018b5c3409a45e" 52 | version = "v1.4.2" 53 | 54 | [[projects]] 55 | digest = "1:645cabccbb4fa8aab25a956cbcbdf6a6845ca736b2c64e197ca7cbb9d210b939" 56 | name = "github.com/spf13/cobra" 57 | packages = ["."] 58 | pruneopts = "UT" 59 | revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" 60 | version = "v0.0.3" 61 | 62 | [[projects]] 63 | digest = "1:c1b1102241e7f645bc8e0c22ae352e8f0dc6484b6cb4d132fa9f24174e0119e2" 64 | name = "github.com/spf13/pflag" 65 | packages = ["."] 66 | pruneopts = "UT" 67 | revision = "298182f68c66c05229eb03ac171abe6e309ee79a" 68 | version = "v1.0.3" 69 | 70 | [[projects]] 71 | branch = "master" 72 | digest = "1:a167b5c532f3245f5a147ade26185f16d6ee8f8d3f6c9846f447e9d8b9705505" 73 | name = "golang.org/x/net" 74 | packages = [ 75 | "html", 76 | "html/atom", 77 | "html/charset" 78 | ] 79 | pruneopts = "UT" 80 | revision = "f4e77d36d62c17c2336347bb2670ddbd02d092b7" 81 | 82 | [[projects]] 83 | digest = "1:3fe612db5a4468ac2846ae481c22bb3250fa67cf03bccb00c06fa8723a3077a8" 84 | name = "golang.org/x/sys" 85 | packages = ["unix"] 86 | pruneopts = "UT" 87 | revision = "7dca6fe1f43775aa6d1334576870ff63f978f539" 88 | 89 | [[projects]] 90 | digest = "1:8a0baffd5559acaa560f854d7d525c02f4fec2d4f8a214398556fb661a10f6e0" 91 | name = "golang.org/x/text" 92 | packages = [ 93 | "encoding", 94 | "encoding/charmap", 95 | "encoding/htmlindex", 96 | "encoding/internal", 97 | "encoding/internal/identifier", 98 | "encoding/japanese", 99 | "encoding/korean", 100 | "encoding/simplifiedchinese", 101 | "encoding/traditionalchinese", 102 | "encoding/unicode", 103 | "internal/gen", 104 | "internal/language", 105 | "internal/language/compact", 106 | "internal/tag", 107 | "internal/utf8internal", 108 | "language", 109 | "runes", 110 | "transform", 111 | "unicode/cldr" 112 | ] 113 | pruneopts = "UT" 114 | revision = "342b2e1fbaa52c93f31447ad2c6abc048c63e475" 115 | version = "v0.3.2" 116 | 117 | [[projects]] 118 | digest = "1:c25289f43ac4a68d88b02245742347c94f1e108c534dda442188015ff80669b3" 119 | name = "google.golang.org/appengine" 120 | packages = ["cloudsql"] 121 | pruneopts = "UT" 122 | revision = "54a98f90d1c46b7731eb8fb305d2a321c30ef610" 123 | version = "v1.5.0" 124 | 125 | [[projects]] 126 | digest = "1:6a8414c6457caa5db639b9f5c084a95b698f2b3cc011151a4b43224a1d8fe0f5" 127 | name = "gopkg.in/iconv.v1" 128 | packages = ["."] 129 | pruneopts = "UT" 130 | revision = "16a760eb7e186ae0e3aedda00d4a1daa4d0701d8" 131 | version = "v1.1.1" 132 | 133 | [solve-meta] 134 | analyzer-name = "dep" 135 | analyzer-version = 1 136 | input-imports = [ 137 | "github.com/asaskevich/EventBus", 138 | "github.com/go-sql-driver/mysql", 139 | "github.com/gomodule/redigo/redis", 140 | "github.com/sirupsen/logrus", 141 | "github.com/spf13/cobra", 142 | "golang.org/x/net/html/charset", 143 | "gopkg.in/iconv.v1" 144 | ] 145 | solver-name = "gps-cdcl" 146 | solver-version = 1 147 | -------------------------------------------------------------------------------- /cmd/guerrillad/serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/flashmob/go-guerrilla" 11 | "github.com/flashmob/go-guerrilla/log" 12 | 13 | // enable the Redis redigo driver 14 | _ "github.com/flashmob/go-guerrilla/backends/storage/redigo" 15 | 16 | // Choose iconv or mail/encoding package which uses golang.org/x/net/html/charset 17 | //_ "github.com/flashmob/go-guerrilla/mail/iconv" 18 | _ "github.com/flashmob/go-guerrilla/mail/encoding" 19 | 20 | "github.com/spf13/cobra" 21 | 22 | _ "github.com/go-sql-driver/mysql" 23 | ) 24 | 25 | const ( 26 | defaultPidFile = "/var/run/go-guerrilla.pid" 27 | ) 28 | 29 | var ( 30 | configPath string 31 | pidFile string 32 | 33 | serveCmd = &cobra.Command{ 34 | Use: "serve", 35 | Short: "start the daemon and start all available servers", 36 | Run: serve, 37 | } 38 | 39 | signalChannel = make(chan os.Signal, 1) // for trapping SIGHUP and friends 40 | mainlog log.Logger 41 | 42 | d guerrilla.Daemon 43 | ) 44 | 45 | func init() { 46 | // log to stderr on startup 47 | var err error 48 | mainlog, err = log.GetLogger(log.OutputStderr.String(), log.InfoLevel.String()) 49 | if err != nil && mainlog != nil { 50 | mainlog.WithError(err).Errorf("Failed creating a logger to %s", log.OutputStderr) 51 | } 52 | cfgFile := "goguerrilla.conf" // deprecated default name 53 | if _, err := os.Stat(cfgFile); err != nil { 54 | cfgFile = "goguerrilla.conf.json" // use the new name 55 | } 56 | serveCmd.PersistentFlags().StringVarP(&configPath, "config", "c", 57 | cfgFile, "Path to the configuration file") 58 | // intentionally didn't specify default pidFile; value from config is used if flag is empty 59 | serveCmd.PersistentFlags().StringVarP(&pidFile, "pidFile", "p", 60 | "", "Path to the pid file") 61 | rootCmd.AddCommand(serveCmd) 62 | } 63 | 64 | func sigHandler() { 65 | signal.Notify(signalChannel, 66 | syscall.SIGHUP, 67 | syscall.SIGTERM, 68 | syscall.SIGQUIT, 69 | syscall.SIGINT, 70 | syscall.SIGKILL, 71 | syscall.SIGUSR1, 72 | os.Kill, 73 | ) 74 | for sig := range signalChannel { 75 | if sig == syscall.SIGHUP { 76 | if ac, err := readConfig(configPath, pidFile); err == nil { 77 | _ = d.ReloadConfig(*ac) 78 | } else { 79 | mainlog.WithError(err).Error("Could not reload config") 80 | } 81 | } else if sig == syscall.SIGUSR1 { 82 | if err := d.ReopenLogs(); err != nil { 83 | mainlog.WithError(err).Error("reopening logs failed") 84 | } 85 | } else if sig == syscall.SIGTERM || sig == syscall.SIGQUIT || sig == syscall.SIGINT || sig == os.Kill { 86 | mainlog.Infof("Shutdown signal caught") 87 | go func() { 88 | select { 89 | // exit if graceful shutdown not finished in 60 sec. 90 | case <-time.After(time.Second * 60): 91 | mainlog.Error("graceful shutdown timed out") 92 | os.Exit(1) 93 | } 94 | }() 95 | d.Shutdown() 96 | mainlog.Infof("Shutdown completed, exiting.") 97 | return 98 | } else { 99 | mainlog.Infof("Shutdown, unknown signal caught") 100 | return 101 | } 102 | } 103 | } 104 | 105 | func serve(cmd *cobra.Command, args []string) { 106 | logVersion() 107 | d = guerrilla.Daemon{Logger: mainlog} 108 | c, err := readConfig(configPath, pidFile) 109 | if err != nil { 110 | mainlog.WithError(err).Fatal("Error while reading config") 111 | } 112 | _ = d.SetConfig(*c) 113 | 114 | // Check that max clients is not greater than system open file limit. 115 | if ok, maxClients, fileLimit := guerrilla.CheckFileLimit(c); !ok { 116 | mainlog.Fatalf("Combined max clients for all servers (%d) is greater than open file limit (%d). "+ 117 | "Please increase your open file limit or decrease max clients.", maxClients, fileLimit) 118 | } 119 | 120 | err = d.Start() 121 | if err != nil { 122 | mainlog.WithError(err).Error("Error(s) when creating new server(s)") 123 | os.Exit(1) 124 | } 125 | sigHandler() 126 | 127 | } 128 | 129 | // ReadConfig is called at startup, or when a SIG_HUP is caught 130 | func readConfig(path string, pidFile string) (*guerrilla.AppConfig, error) { 131 | // Load in the config. 132 | // Note here is the only place we can make an exception to the 133 | // "treat config values as immutable". For example, here the 134 | // command line flags can override config values 135 | appConfig, err := d.LoadConfig(path) 136 | if err != nil { 137 | return &appConfig, fmt.Errorf("could not read config file: %s", err.Error()) 138 | } 139 | // override config pidFile with with flag from the command line 140 | if len(pidFile) > 0 { 141 | appConfig.PidFile = pidFile 142 | } else if len(appConfig.PidFile) == 0 { 143 | appConfig.PidFile = defaultPidFile 144 | } 145 | if verbose { 146 | appConfig.LogLevel = "debug" 147 | } 148 | return &appConfig, nil 149 | } 150 | -------------------------------------------------------------------------------- /mail/envelope_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | // Test MimeHeader decoding, not using iconv 11 | func TestMimeHeaderDecode(t *testing.T) { 12 | 13 | /* 14 | Normally this would fail if not using iconv 15 | str := MimeHeaderDecode("=?ISO-2022-JP?B?GyRCIVo9dztSOWJAOCVBJWMbKEI=?=") 16 | if i := strings.Index(str, "【女子高生チャ"); i != 0 { 17 | t.Error("expecting 【女子高生チャ, got:", str) 18 | } 19 | */ 20 | 21 | str := MimeHeaderDecode("=?utf-8?B?55So5oi34oCcRXBpZGVtaW9sb2d5IGluIG51cnNpbmcgYW5kIGg=?= =?utf-8?B?ZWFsdGggY2FyZSBlQm9vayByZWFkL2F1ZGlvIGlkOm8=?= =?utf-8?B?cTNqZWVr4oCd5Zyo572R56uZ4oCcU1BZ5Lit5paH5a6Y5pa5572R56uZ4oCd?= =?utf-8?B?55qE5biQ5Y+36K+m5oOF?=") 22 | if i := strings.Index(str, "用户“Epidemiology in nursing and health care eBook read/audio id:oq3jeek”在网站“SPY中文官方网站”的帐号详情"); i != 0 { 23 | t.Error("\nexpecting \n用户“Epidemiology in nursing and h ealth care eBook read/audio id:oq3jeek”在网站“SPY中文官方网站”的帐号详情\n got:\n", str) 24 | } 25 | str = MimeHeaderDecode("=?ISO-8859-1?Q?Andr=E9?= Pirard ") 26 | if strings.Index(str, "André Pirard") != 0 { 27 | t.Error("expecting André Pirard, got:", str) 28 | } 29 | } 30 | 31 | // TestMimeHeaderDecodeNone tests strings without any encoded words 32 | func TestMimeHeaderDecodeNone(t *testing.T) { 33 | // in the best case, there will be nothing to decode 34 | str := MimeHeaderDecode("Andre Pirard ") 35 | if strings.Index(str, "Andre Pirard") != 0 { 36 | t.Error("expecting Andre Pirard, got:", str) 37 | } 38 | 39 | } 40 | 41 | func TestAddressPostmaster(t *testing.T) { 42 | addr := &Address{User: "postmaster"} 43 | str := addr.String() 44 | if str != "postmaster" { 45 | t.Error("it was not postmaster,", str) 46 | } 47 | } 48 | 49 | func TestAddressNull(t *testing.T) { 50 | addr := &Address{NullPath: true} 51 | str := addr.String() 52 | if str != "" { 53 | t.Error("it was not empty", str) 54 | } 55 | } 56 | 57 | func TestNewAddress(t *testing.T) { 58 | 59 | addr, err := NewAddress("") 60 | if err == nil { 61 | t.Error("there should be an error:", err) 62 | } 63 | 64 | addr, err = NewAddress(`Gogh Fir `) 65 | if err != nil { 66 | t.Error("there should be no error:", addr.Host, err) 67 | } 68 | } 69 | 70 | func TestQuotedAddress(t *testing.T) { 71 | 72 | str := `<" yo-- man wazz'''up? surprise \surprise, this is POSSIBLE@fake.com "@example.com>` 73 | //str = `<"post\master">` 74 | addr, err := NewAddress(str) 75 | if err != nil { 76 | t.Error("there should be no error:", err) 77 | } 78 | 79 | str = addr.String() 80 | // in this case, string should remove the unnecessary escape 81 | if strings.Contains(str, "\\surprise") { 82 | t.Error("there should be no \\surprise:", err) 83 | } 84 | 85 | } 86 | 87 | func TestAddressWithIP(t *testing.T) { 88 | str := `<" yo-- man wazz'''up? surprise \surprise, this is POSSIBLE@fake.com "@[64.233.160.71]>` 89 | addr, err := NewAddress(str) 90 | if err != nil { 91 | t.Error("there should be no error:", err) 92 | } else if addr.IP == nil { 93 | t.Error("expecting the address host to be an IP") 94 | } 95 | } 96 | 97 | func TestEnvelope(t *testing.T) { 98 | e := NewEnvelope("127.0.0.1", 22) 99 | 100 | e.QueuedId = "abc123" 101 | e.Helo = "helo.example.com" 102 | e.MailFrom = Address{User: "test", Host: "example.com"} 103 | e.TLS = true 104 | e.RemoteIP = "222.111.233.121" 105 | to := Address{User: "test", Host: "example.com"} 106 | e.PushRcpt(to) 107 | if to.String() != "test@example.com" { 108 | t.Error("to does not equal test@example.com, it was:", to.String()) 109 | } 110 | e.Data.WriteString("Subject: Test\n\nThis is a test nbnb nbnb hgghgh nnnbnb nbnbnb nbnbn.") 111 | 112 | addHead := "Delivered-To: " + to.String() + "\n" 113 | addHead += "Received: from " + e.Helo + " (" + e.Helo + " [" + e.RemoteIP + "])\n" 114 | e.DeliveryHeader = addHead 115 | 116 | r := e.NewReader() 117 | 118 | data, _ := ioutil.ReadAll(r) 119 | if len(data) != e.Len() { 120 | t.Error("e.Len() is incorrect, it shown ", e.Len(), " but we wanted ", len(data)) 121 | } 122 | if err := e.ParseHeaders(); err != nil && err != io.EOF { 123 | t.Error("cannot parse headers:", err) 124 | return 125 | } 126 | if e.Subject != "Test" { 127 | t.Error("Subject expecting: Test, got:", e.Subject) 128 | } 129 | 130 | } 131 | 132 | func TestEncodedWordAhead(t *testing.T) { 133 | str := "=?ISO-8859-1?Q?Andr=E9?= Pirard " 134 | if hasEncodedWordAhead(str, 24) != -1 { 135 | t.Error("expecting no encoded word ahead") 136 | } 137 | 138 | str = "=?ISO-8859-1?Q?Andr=E9?= =" 139 | if hasEncodedWordAhead(str, 24) != -1 { 140 | t.Error("expecting no encoded word ahead") 141 | } 142 | 143 | str = "=?ISO-8859-1?Q?Andr=E9?= =?ISO-8859-1?Q?Andr=E9?=" 144 | if hasEncodedWordAhead(str, 24) == -1 { 145 | t.Error("expecting an encoded word ahead") 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /log/hook.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bufio" 5 | log "github.com/sirupsen/logrus" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | // custom logrus hook 14 | 15 | // hookMu ensures all io operations are synced. Always on exported functions 16 | var hookMu sync.Mutex 17 | 18 | // LoggerHook extends the log.Hook interface by adding Reopen() and Rename() 19 | type LoggerHook interface { 20 | log.Hook 21 | Reopen() error 22 | } 23 | type LogrusHook struct { 24 | w io.Writer 25 | // file descriptor, can be re-opened 26 | fd *os.File 27 | // filename to the file descriptor 28 | fname string 29 | // txtFormatter that doesn't use colors 30 | plainTxtFormatter *log.TextFormatter 31 | 32 | mu sync.Mutex 33 | } 34 | 35 | // newLogrusHook creates a new hook. dest can be a file name or one of the following strings: 36 | // "stderr" - log to stderr, lines will be written to os.Stdout 37 | // "stdout" - log to stdout, lines will be written to os.Stdout 38 | // "off" - no log, lines will be written to ioutil.Discard 39 | func NewLogrusHook(dest string) (LoggerHook, error) { 40 | hookMu.Lock() 41 | defer hookMu.Unlock() 42 | hook := LogrusHook{fname: dest} 43 | err := hook.setup(dest) 44 | return &hook, err 45 | } 46 | 47 | type OutputOption int 48 | 49 | const ( 50 | OutputStderr OutputOption = 1 + iota 51 | OutputStdout 52 | OutputOff 53 | OutputNull 54 | OutputFile 55 | ) 56 | 57 | var outputOptions = [...]string{ 58 | "stderr", 59 | "stdout", 60 | "off", 61 | "", 62 | "file", 63 | } 64 | 65 | func (o OutputOption) String() string { 66 | return outputOptions[o-1] 67 | } 68 | 69 | func parseOutputOption(str string) OutputOption { 70 | switch str { 71 | case "stderr": 72 | return OutputStderr 73 | case "stdout": 74 | return OutputStdout 75 | case "off": 76 | return OutputOff 77 | case "": 78 | return OutputNull 79 | } 80 | return OutputFile 81 | } 82 | 83 | // Setup sets the hook's writer w and file descriptor fd 84 | // assumes the hook.fd is closed and nil 85 | func (hook *LogrusHook) setup(dest string) error { 86 | 87 | out := parseOutputOption(dest) 88 | if out == OutputNull || out == OutputStderr { 89 | hook.w = os.Stderr 90 | } else if out == OutputStdout { 91 | hook.w = os.Stdout 92 | } else if out == OutputOff { 93 | hook.w = ioutil.Discard 94 | } else { 95 | if _, err := os.Stat(dest); err == nil { 96 | // file exists open the file for appending 97 | if err := hook.openAppend(dest); err != nil { 98 | return err 99 | } 100 | } else { 101 | // create the file 102 | if err := hook.openCreate(dest); err != nil { 103 | return err 104 | } 105 | } 106 | } 107 | // disable colors when writing to file 108 | if hook.fd != nil { 109 | hook.plainTxtFormatter = &log.TextFormatter{DisableColors: true} 110 | } 111 | return nil 112 | } 113 | 114 | // openAppend opens the dest file for appending. Default to os.Stderr if it can't open dest 115 | func (hook *LogrusHook) openAppend(dest string) (err error) { 116 | fd, err := os.OpenFile(dest, os.O_APPEND|os.O_WRONLY, 0644) 117 | if err != nil { 118 | log.WithError(err).Error("Could not open log file for appending") 119 | hook.w = os.Stderr 120 | hook.fd = nil 121 | return 122 | } 123 | hook.w = bufio.NewWriter(fd) 124 | hook.fd = fd 125 | return 126 | } 127 | 128 | // openCreate creates a new dest file for appending. Default to os.Stderr if it can't open dest 129 | func (hook *LogrusHook) openCreate(dest string) (err error) { 130 | fd, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 131 | if err != nil { 132 | log.WithError(err).Error("Could not create log file") 133 | hook.w = os.Stderr 134 | hook.fd = nil 135 | return 136 | } 137 | hook.w = bufio.NewWriter(fd) 138 | hook.fd = fd 139 | return 140 | } 141 | 142 | // Fire implements the logrus Hook interface. It disables color text formatting if writing to a file 143 | func (hook *LogrusHook) Fire(entry *log.Entry) error { 144 | hookMu.Lock() 145 | defer hookMu.Unlock() 146 | 147 | line, err := entry.String() 148 | 149 | if err == nil { 150 | r := strings.NewReader(line) 151 | if _, err = io.Copy(hook.w, r); err != nil { 152 | return err 153 | } 154 | if wb, ok := hook.w.(*bufio.Writer); ok { 155 | if err := wb.Flush(); err != nil { 156 | return err 157 | } 158 | if hook.fd != nil { 159 | err = hook.fd.Sync() 160 | } 161 | } 162 | return err 163 | } 164 | return err 165 | } 166 | 167 | // Levels implements the logrus Hook interface 168 | func (hook *LogrusHook) Levels() []log.Level { 169 | return log.AllLevels 170 | } 171 | 172 | // Reopen closes and re-open log file descriptor, which is a special feature of this hook 173 | func (hook *LogrusHook) Reopen() error { 174 | hookMu.Lock() 175 | defer hookMu.Unlock() 176 | var err error 177 | if hook.fd != nil { 178 | if err = hook.fd.Close(); err != nil { 179 | return err 180 | } 181 | // The file could have been re-named by an external program such as logrotate(8) 182 | _, err := os.Stat(hook.fname) 183 | if err != nil { 184 | // The file doesn't exist, create a new one. 185 | return hook.openCreate(hook.fname) 186 | } 187 | return hook.openAppend(hook.fname) 188 | } 189 | return err 190 | 191 | } 192 | -------------------------------------------------------------------------------- /pool.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import ( 4 | "errors" 5 | "github.com/flashmob/go-guerrilla/log" 6 | "github.com/flashmob/go-guerrilla/mail" 7 | "net" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | ) 12 | 13 | var ( 14 | ErrPoolShuttingDown = errors.New("server pool: shutting down") 15 | ) 16 | 17 | // a struct can be pooled if it has the following interface 18 | type Poolable interface { 19 | // ability to set read/write timeout 20 | setTimeout(t time.Duration) error 21 | // set a new connection and client id 22 | init(c net.Conn, clientID uint64, ep *mail.Pool) 23 | // get a unique id 24 | getID() uint64 25 | kill() 26 | } 27 | 28 | // Pool holds Clients. 29 | type Pool struct { 30 | // clients that are ready to be borrowed 31 | pool chan Poolable 32 | // semaphore to control number of maximum borrowed clients 33 | sem chan bool 34 | // book-keeping of clients that have been lent 35 | activeClients lentClients 36 | isShuttingDownFlg atomic.Value 37 | poolGuard sync.Mutex 38 | ShutdownChan chan int 39 | } 40 | 41 | type lentClients struct { 42 | m map[uint64]Poolable 43 | mu sync.Mutex // guards access to this struct 44 | wg sync.WaitGroup 45 | } 46 | 47 | // maps the callback on all lentClients 48 | func (c *lentClients) mapAll(callback func(p Poolable)) { 49 | defer c.mu.Unlock() 50 | c.mu.Lock() 51 | for _, item := range c.m { 52 | callback(item) 53 | } 54 | } 55 | 56 | // operation performs an operation on a Poolable item using the callback 57 | func (c *lentClients) operation(callback func(p Poolable), item Poolable) { 58 | defer c.mu.Unlock() 59 | c.mu.Lock() 60 | callback(item) 61 | } 62 | 63 | // NewPool creates a new pool of Clients. 64 | func NewPool(poolSize int) *Pool { 65 | return &Pool{ 66 | pool: make(chan Poolable, poolSize), 67 | sem: make(chan bool, poolSize), 68 | activeClients: lentClients{m: make(map[uint64]Poolable, poolSize)}, 69 | ShutdownChan: make(chan int, 1), 70 | } 71 | } 72 | func (p *Pool) Start() { 73 | p.isShuttingDownFlg.Store(true) 74 | } 75 | 76 | // Lock the pool from borrowing then remove all active clients 77 | // each active client's timeout is lowered to 1 sec and notified 78 | // to stop accepting commands 79 | func (p *Pool) ShutdownState() { 80 | const aVeryLowTimeout = 1 81 | p.poolGuard.Lock() // ensure no other thread is in the borrowing now 82 | defer p.poolGuard.Unlock() 83 | p.isShuttingDownFlg.Store(true) // no more borrowing 84 | p.ShutdownChan <- 1 // release any waiting p.sem 85 | 86 | // set a low timeout (let the clients finish whatever the're doing) 87 | p.activeClients.mapAll(func(p Poolable) { 88 | if err := p.setTimeout(time.Duration(int64(aVeryLowTimeout))); err != nil { 89 | p.kill() 90 | } 91 | }) 92 | 93 | } 94 | 95 | func (p *Pool) ShutdownWait() { 96 | p.poolGuard.Lock() // ensure no other thread is in the borrowing now 97 | defer p.poolGuard.Unlock() 98 | p.activeClients.wg.Wait() // wait for clients to finish 99 | if len(p.ShutdownChan) > 0 { 100 | // drain 101 | <-p.ShutdownChan 102 | } 103 | p.isShuttingDownFlg.Store(false) 104 | } 105 | 106 | // returns true if the pool is shutting down 107 | func (p *Pool) IsShuttingDown() bool { 108 | if value, ok := p.isShuttingDownFlg.Load().(bool); ok { 109 | return value 110 | } 111 | return false 112 | } 113 | 114 | // set a timeout for all lent clients 115 | func (p *Pool) SetTimeout(duration time.Duration) { 116 | p.activeClients.mapAll(func(p Poolable) { 117 | if err := p.setTimeout(duration); err != nil { 118 | p.kill() 119 | } 120 | }) 121 | } 122 | 123 | // Gets the number of active clients that are currently 124 | // out of the pool and busy serving 125 | func (p *Pool) GetActiveClientsCount() int { 126 | return len(p.sem) 127 | } 128 | 129 | // Borrow a Client from the pool. Will block if len(activeClients) > maxClients 130 | func (p *Pool) Borrow(conn net.Conn, clientID uint64, logger log.Logger, ep *mail.Pool) (Poolable, error) { 131 | p.poolGuard.Lock() 132 | defer p.poolGuard.Unlock() 133 | 134 | var c Poolable 135 | if yes, really := p.isShuttingDownFlg.Load().(bool); yes && really { 136 | // pool is shutting down. 137 | return c, ErrPoolShuttingDown 138 | } 139 | select { 140 | case p.sem <- true: // block the client from serving until there is room 141 | select { 142 | case c = <-p.pool: 143 | c.init(conn, clientID, ep) 144 | default: 145 | c = NewClient(conn, clientID, logger, ep) 146 | } 147 | p.activeClientsAdd(c) 148 | 149 | case <-p.ShutdownChan: // unblock p.sem when shutting down 150 | // pool is shutting down. 151 | return c, ErrPoolShuttingDown 152 | } 153 | return c, nil 154 | } 155 | 156 | // Return returns a Client back to the pool. 157 | func (p *Pool) Return(c Poolable) { 158 | p.activeClientsRemove(c) 159 | select { 160 | case p.pool <- c: 161 | default: 162 | // hasta la vista, baby... 163 | } 164 | 165 | <-p.sem // make room for the next serving client 166 | } 167 | 168 | func (p *Pool) activeClientsAdd(c Poolable) { 169 | p.activeClients.operation(func(item Poolable) { 170 | p.activeClients.wg.Add(1) 171 | p.activeClients.m[c.getID()] = item 172 | }, c) 173 | } 174 | 175 | func (p *Pool) activeClientsRemove(c Poolable) { 176 | p.activeClients.operation(func(item Poolable) { 177 | delete(p.activeClients.m, item.getID()) 178 | p.activeClients.wg.Done() 179 | }, c) 180 | } 181 | -------------------------------------------------------------------------------- /tests/testcert/generate_cert.go: -------------------------------------------------------------------------------- 1 | // adopted from https://golang.org/src/crypto/tls/generate_cert.go?m=text 2 | 3 | // Generate a self-signed X.509 certificate for a TLS server. 4 | 5 | package testcert 6 | 7 | import ( 8 | "crypto/ecdsa" 9 | "crypto/elliptic" 10 | "crypto/rand" 11 | "crypto/rsa" 12 | "crypto/x509" 13 | "crypto/x509/pkix" 14 | "encoding/pem" 15 | 16 | "errors" 17 | "fmt" 18 | "log" 19 | "math/big" 20 | "net" 21 | "os" 22 | "strings" 23 | "time" 24 | ) 25 | 26 | /* 27 | var ( 28 | host = flag.String("host", "", "Comma-separated hostnames and IPs to generate a certificate for") 29 | validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") 30 | validFor = flag.Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for") 31 | isCA = flag.Bool("ca", false, "whether this cert should be its own Certificate Authority") 32 | rsaBits = flag.Int("rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set") 33 | ecdsaCurve = flag.String("ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256, P384, P521") 34 | ) 35 | */ 36 | 37 | func publicKey(priv interface{}) interface{} { 38 | switch k := priv.(type) { 39 | case *rsa.PrivateKey: 40 | return &k.PublicKey 41 | case *ecdsa.PrivateKey: 42 | return &k.PublicKey 43 | default: 44 | return nil 45 | } 46 | } 47 | 48 | func pemBlockForKey(priv interface{}) (*pem.Block, error) { 49 | switch k := priv.(type) { 50 | case *rsa.PrivateKey: 51 | return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}, nil 52 | case *ecdsa.PrivateKey: 53 | b, err := x509.MarshalECPrivateKey(k) 54 | if err != nil { 55 | err = errors.New(fmt.Sprintf("Unable to marshal ECDSA private key: %v", err)) 56 | } 57 | return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}, err 58 | default: 59 | return nil, errors.New("not a private key") 60 | } 61 | } 62 | 63 | // validFrom - Creation date formatted as Jan 1 15:04:05 2011 or "" 64 | 65 | func GenerateCert(host string, validFrom string, validFor time.Duration, isCA bool, rsaBits int, ecdsaCurve string, dirPrefix string) (err error) { 66 | 67 | if len(host) == 0 { 68 | log.Fatalf("Missing required --host parameter") 69 | } 70 | 71 | var priv interface{} 72 | switch ecdsaCurve { 73 | case "": 74 | priv, err = rsa.GenerateKey(rand.Reader, rsaBits) 75 | case "P224": 76 | priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader) 77 | case "P256": 78 | priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 79 | case "P384": 80 | priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 81 | case "P521": 82 | priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) 83 | default: 84 | err = errors.New(fmt.Sprintf("Unrecognized elliptic curve: %q", ecdsaCurve)) 85 | } 86 | if err != nil { 87 | log.Fatalf("failed to generate private key: %s", err) 88 | return 89 | } 90 | 91 | var notBefore time.Time 92 | if len(validFrom) == 0 { 93 | notBefore = time.Now() 94 | } else { 95 | notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom) 96 | if err != nil { 97 | err = errors.New(fmt.Sprintf("Failed to parse creation date: %s\n", err)) 98 | return 99 | } 100 | } 101 | 102 | notAfter := notBefore.Add(validFor) 103 | 104 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 105 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 106 | if err != nil { 107 | log.Fatalf("failed to generate serial number: %s", err) 108 | } 109 | 110 | template := x509.Certificate{ 111 | SerialNumber: serialNumber, 112 | Subject: pkix.Name{ 113 | Organization: []string{"Acme Co"}, 114 | }, 115 | NotBefore: notBefore, 116 | NotAfter: notAfter, 117 | 118 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 119 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 120 | BasicConstraintsValid: true, 121 | } 122 | 123 | hosts := strings.Split(host, ",") 124 | for _, h := range hosts { 125 | if ip := net.ParseIP(h); ip != nil { 126 | template.IPAddresses = append(template.IPAddresses, ip) 127 | } else { 128 | template.DNSNames = append(template.DNSNames, h) 129 | } 130 | } 131 | 132 | if isCA { 133 | template.IsCA = true 134 | template.KeyUsage |= x509.KeyUsageCertSign 135 | } 136 | 137 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) 138 | if err != nil { 139 | log.Fatalf("Failed to create certificate: %s", err) 140 | } 141 | 142 | certOut, err := os.Create(dirPrefix + host + ".cert.pem") 143 | if err != nil { 144 | log.Fatalf("failed to open cert.pem for writing: %s", err) 145 | } 146 | err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 147 | if err != nil { 148 | return 149 | } 150 | if err = certOut.Sync(); err != nil { 151 | return 152 | } 153 | if err = certOut.Close(); err != nil { 154 | return 155 | } 156 | 157 | keyOut, err := os.OpenFile(dirPrefix+host+".key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 158 | if err != nil { 159 | log.Print("failed to open key.pem for writing:", err) 160 | return 161 | } 162 | var block *pem.Block 163 | if block, err = pemBlockForKey(priv); err != nil { 164 | return err 165 | } 166 | if err = pem.Encode(keyOut, block); err != nil { 167 | return err 168 | } 169 | if err = keyOut.Sync(); err != nil { 170 | return err 171 | } 172 | if err = keyOut.Close(); err != nil { 173 | return err 174 | } 175 | return 176 | 177 | } 178 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "io" 6 | "io/ioutil" 7 | "net" 8 | "os" 9 | "sync" 10 | ) 11 | 12 | // The following are taken from logrus 13 | const ( 14 | // PanicLevel level, highest level of severity. Logs and then calls panic with the 15 | // message passed to Debug, Info, ... 16 | PanicLevel Level = iota 17 | // FatalLevel level. Logs and then calls `os.Exit(1)`. It will exit even if the 18 | // logging level is set to Panic. 19 | FatalLevel 20 | // ErrorLevel level. Logs. Used for errors that should definitely be noted. 21 | // Commonly used for hooks to send errors to an error tracking service. 22 | ErrorLevel 23 | // WarnLevel level. Non-critical entries that deserve eyes. 24 | WarnLevel 25 | // InfoLevel level. General operational entries about what's going on inside the 26 | // application. 27 | InfoLevel 28 | // DebugLevel level. Usually only enabled when debugging. Very verbose logging. 29 | DebugLevel 30 | ) 31 | 32 | type Level uint8 33 | 34 | // Convert the Level to a string. E.g. PanicLevel becomes "panic". 35 | func (level Level) String() string { 36 | switch level { 37 | case DebugLevel: 38 | return "debug" 39 | case InfoLevel: 40 | return "info" 41 | case WarnLevel: 42 | return "warning" 43 | case ErrorLevel: 44 | return "error" 45 | case FatalLevel: 46 | return "fatal" 47 | case PanicLevel: 48 | return "panic" 49 | } 50 | 51 | return "unknown" 52 | } 53 | 54 | type Logger interface { 55 | log.FieldLogger 56 | WithConn(conn net.Conn) *log.Entry 57 | Reopen() error 58 | GetLogDest() string 59 | SetLevel(level string) 60 | GetLevel() string 61 | IsDebug() bool 62 | AddHook(h log.Hook) 63 | } 64 | 65 | // Implements the Logger interface 66 | // It's a logrus logger wrapper that contains an instance of our LoggerHook 67 | type HookedLogger struct { 68 | 69 | // satisfy the log.FieldLogger interface 70 | *log.Logger 71 | 72 | h LoggerHook 73 | 74 | // destination, file name or "stderr", "stdout" or "off" 75 | dest string 76 | 77 | oo OutputOption 78 | } 79 | 80 | type loggerKey struct { 81 | dest, level string 82 | } 83 | 84 | type loggerCache map[loggerKey]Logger 85 | 86 | // loggers store the cached loggers created by NewLogger 87 | var loggers struct { 88 | cache loggerCache 89 | // mutex guards the cache 90 | sync.Mutex 91 | } 92 | 93 | // GetLogger returns a struct that implements Logger (i.e HookedLogger) with a custom hook. 94 | // It may be new or already created, (ie. singleton factory pattern) 95 | // The hook has been initialized with dest 96 | // dest can can be a path to a file, or the following string values: 97 | // "off" - disable any log output 98 | // "stdout" - write to standard output 99 | // "stderr" - write to standard error 100 | // If the file doesn't exists, a new file will be created. Otherwise it will be appended 101 | // Each Logger returned is cached on dest, subsequent call will get the cached logger if dest matches 102 | // If there was an error, the log will revert to stderr instead of using a custom hook 103 | 104 | func GetLogger(dest string, level string) (Logger, error) { 105 | loggers.Lock() 106 | defer loggers.Unlock() 107 | key := loggerKey{dest, level} 108 | if loggers.cache == nil { 109 | loggers.cache = make(loggerCache, 1) 110 | } else { 111 | if l, ok := loggers.cache[key]; ok { 112 | // return the one we found in the cache 113 | return l, nil 114 | } 115 | } 116 | o := parseOutputOption(dest) 117 | logrus, err := newLogrus(o, level) 118 | if err != nil { 119 | return nil, err 120 | } 121 | l := &HookedLogger{dest: dest} 122 | l.Logger = logrus 123 | 124 | // cache it 125 | loggers.cache[key] = l 126 | 127 | if o != OutputFile { 128 | return l, nil 129 | } 130 | // we'll use the hook to output instead 131 | logrus.Out = ioutil.Discard 132 | // setup the hook 133 | h, err := NewLogrusHook(dest) 134 | if err != nil { 135 | // revert back to stderr 136 | logrus.Out = os.Stderr 137 | return l, err 138 | } 139 | 140 | logrus.Hooks.Add(h) 141 | l.h = h 142 | 143 | return l, nil 144 | } 145 | 146 | func newLogrus(o OutputOption, level string) (*log.Logger, error) { 147 | logLevel, err := log.ParseLevel(level) 148 | if err != nil { 149 | return nil, err 150 | } 151 | var out io.Writer 152 | 153 | if o != OutputFile { 154 | if o == OutputNull || o == OutputStderr { 155 | out = os.Stderr 156 | } else if o == OutputStdout { 157 | out = os.Stdout 158 | } else if o == OutputOff { 159 | out = ioutil.Discard 160 | } 161 | } else { 162 | // we'll use a hook to output instead 163 | out = ioutil.Discard 164 | } 165 | 166 | logger := &log.Logger{ 167 | Out: out, 168 | Formatter: new(log.TextFormatter), 169 | Hooks: make(log.LevelHooks), 170 | Level: logLevel, 171 | } 172 | 173 | return logger, nil 174 | } 175 | 176 | // AddHook adds a new logrus hook 177 | func (l *HookedLogger) AddHook(h log.Hook) { 178 | log.AddHook(h) 179 | } 180 | 181 | func (l *HookedLogger) IsDebug() bool { 182 | return l.GetLevel() == log.DebugLevel.String() 183 | } 184 | 185 | // SetLevel sets a log level, one of the LogLevels 186 | func (l *HookedLogger) SetLevel(level string) { 187 | var logLevel log.Level 188 | var err error 189 | if logLevel, err = log.ParseLevel(level); err != nil { 190 | return 191 | } 192 | log.SetLevel(logLevel) 193 | } 194 | 195 | // GetLevel gets the current log level 196 | func (l *HookedLogger) GetLevel() string { 197 | return l.Level.String() 198 | } 199 | 200 | // Reopen closes the log file and re-opens it 201 | func (l *HookedLogger) Reopen() error { 202 | if l.h == nil { 203 | return nil 204 | } 205 | return l.h.Reopen() 206 | } 207 | 208 | // GetLogDest Gets the file name 209 | func (l *HookedLogger) GetLogDest() string { 210 | return l.dest 211 | } 212 | 213 | // WithConn extends logrus to be able to log with a net.Conn 214 | func (l *HookedLogger) WithConn(conn net.Conn) *log.Entry { 215 | var addr = "unknown" 216 | if conn != nil { 217 | addr = conn.RemoteAddr().String() 218 | } 219 | return l.WithField("addr", addr) 220 | } 221 | -------------------------------------------------------------------------------- /mail/rfc5321/address.go: -------------------------------------------------------------------------------- 1 | package rfc5321 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | ) 7 | 8 | // Parse productions according to ABNF in RFC5322 9 | type RFC5322 struct { 10 | AddressList 11 | Parser 12 | addr SingleAddress 13 | } 14 | 15 | type AddressList struct { 16 | List []SingleAddress 17 | Group string 18 | } 19 | 20 | type SingleAddress struct { 21 | DisplayName string 22 | DisplayNameQuoted bool 23 | LocalPart string 24 | LocalPartQuoted bool 25 | Domain string 26 | IP net.IP 27 | NullPath bool 28 | } 29 | 30 | var ( 31 | errNotAtom = errors.New("not atom") 32 | errExpectingAngleAddress = errors.New("not angle address") 33 | errNotAWord = errors.New("not a word") 34 | errExpectingColon = errors.New("expecting : ") 35 | errExpectingSemicolon = errors.New("expecting ; ") 36 | errExpectingAngleClose = errors.New("expecting >") 37 | errExpectingAngleOpen = errors.New("< expected") 38 | errQuotedUnclosed = errors.New("quoted string not closed") 39 | ) 40 | 41 | // Address parses the "address" production specified in RFC5322 42 | // address = mailbox / group 43 | func (s *RFC5322) Address(input []byte) (AddressList, error) { 44 | s.set(input) 45 | s.next() 46 | s.List = nil 47 | s.addr = SingleAddress{} 48 | if err := s.mailbox(); err != nil { 49 | if s.ch == ':' { 50 | if groupErr := s.group(); groupErr != nil { 51 | return s.AddressList, groupErr 52 | } else { 53 | err = nil 54 | } 55 | } 56 | return s.AddressList, err 57 | 58 | } 59 | return s.AddressList, nil 60 | } 61 | 62 | // group = display-name ":" [group-List] ";" [CFWS] 63 | func (s *RFC5322) group() error { 64 | if s.addr.DisplayName == "" { 65 | if err := s.displayName(); err != nil { 66 | return err 67 | } 68 | } else { 69 | s.Group = s.addr.DisplayName 70 | s.addr.DisplayName = "" 71 | } 72 | if s.ch != ':' { 73 | return errExpectingColon 74 | } 75 | s.next() 76 | _ = s.groupList() 77 | s.skipSpace() 78 | if s.ch != ';' { 79 | return errExpectingSemicolon 80 | } 81 | return nil 82 | } 83 | 84 | // mailbox = name-addr / addr-spec 85 | func (s *RFC5322) mailbox() error { 86 | pos := s.pos // save the position 87 | if err := s.nameAddr(); err != nil { 88 | if err == errExpectingAngleAddress && s.ch != ':' { // ':' means it's a group 89 | // we'll attempt to parse as an email address without angle brackets 90 | s.addr.DisplayName = "" 91 | s.addr.DisplayNameQuoted = false 92 | s.pos = pos - 1 //- 1 // rewind to the saved position 93 | if s.pos > -1 { 94 | s.ch = s.buf[s.pos] 95 | } 96 | if err = s.Parser.mailbox(); err != nil { 97 | return err 98 | } 99 | s.addAddress() 100 | } else { 101 | return err 102 | } 103 | } 104 | return nil 105 | } 106 | 107 | // addAddress ads the current address to the List 108 | func (s *RFC5322) addAddress() { 109 | s.addr.LocalPart = s.LocalPart 110 | s.addr.LocalPartQuoted = s.LocalPartQuotes 111 | s.addr.Domain = s.Domain 112 | s.addr.IP = s.IP 113 | s.List = append(s.List, s.addr) 114 | s.addr = SingleAddress{} 115 | } 116 | 117 | // nameAddr consumes the name-addr production. 118 | // name-addr = [display-name] angle-addr 119 | func (s *RFC5322) nameAddr() error { 120 | _ = s.displayName() 121 | if s.ch == '<' { 122 | if err := s.angleAddr(); err != nil { 123 | return err 124 | } 125 | s.next() 126 | if s.ch != '>' { 127 | return errExpectingAngleClose 128 | } 129 | s.addAddress() 130 | return nil 131 | } else { 132 | return errExpectingAngleAddress 133 | } 134 | 135 | } 136 | 137 | // angleAddr consumes the angle-addr production 138 | // angle-addr = [CFWS] "<" addr-spec ">" [CFWS] / obs-angle-addr 139 | func (s *RFC5322) angleAddr() error { 140 | s.skipSpace() 141 | if s.ch != '<' { 142 | return errExpectingAngleOpen 143 | } 144 | // addr-spec = local-part "@" domain 145 | if err := s.Parser.mailbox(); err != nil { 146 | return err 147 | } 148 | s.skipSpace() 149 | return nil 150 | } 151 | 152 | // displayName consumes the display-name production: 153 | // display-name = phrase 154 | // phrase = 1*word / obs-phrase 155 | func (s *RFC5322) displayName() error { 156 | defer func() { 157 | if s.accept.Len() > 0 { 158 | s.addr.DisplayName = s.accept.String() 159 | s.accept.Reset() 160 | } 161 | }() 162 | // phrase 163 | if err := s.word(); err != nil { 164 | return err 165 | } 166 | for { 167 | err := s.word() 168 | if err != nil { 169 | return nil 170 | } 171 | } 172 | } 173 | 174 | // quotedString consumes a quoted-string production 175 | func (s *RFC5322) quotedString() error { 176 | if s.ch == '"' { 177 | if err := s.Parser.QcontentSMTP(); err != nil { 178 | return err 179 | } 180 | if s.ch != '"' { 181 | return errQuotedUnclosed 182 | } else { 183 | // accept the " 184 | s.next() 185 | } 186 | } 187 | return nil 188 | } 189 | 190 | // word = atom / quoted-string 191 | func (s *RFC5322) word() error { 192 | if s.ch == '"' { 193 | s.addr.DisplayNameQuoted = true 194 | return s.quotedString() 195 | } else if s.isAtext(s.ch) || s.ch == ' ' || s.ch == '\t' { 196 | return s.atom() 197 | } 198 | return errNotAWord 199 | } 200 | 201 | // atom = [CFWS] 1*atext [CFWS] 202 | func (s *RFC5322) atom() error { 203 | s.skipSpace() 204 | if !s.isAtext(s.ch) { 205 | return errNotAtom 206 | } 207 | for { 208 | if s.isAtext(s.ch) { 209 | s.accept.WriteByte(s.ch) 210 | s.next() 211 | } else { 212 | skipped := s.skipSpace() 213 | if !s.isAtext(s.ch) { 214 | return nil 215 | } 216 | if skipped > 0 { 217 | s.accept.WriteByte(' ') 218 | } 219 | s.accept.WriteByte(s.ch) 220 | s.next() 221 | } 222 | } 223 | } 224 | 225 | // groupList consumes the "group-List" production: 226 | // group-List = mailbox-List / CFWS / obs-group-List 227 | func (s *RFC5322) groupList() error { 228 | // mailbox-list = (mailbox *("," mailbox)) 229 | if err := s.mailbox(); err != nil { 230 | return err 231 | } 232 | s.next() 233 | for { 234 | s.skipSpace() 235 | if s.ch != ',' { 236 | return nil 237 | } 238 | s.next() 239 | s.skipSpace() 240 | if err := s.mailbox(); err != nil { 241 | return err 242 | } 243 | s.next() 244 | } 245 | } 246 | 247 | // skipSpace skips vertical space by calling next(), returning the count of spaces skipped 248 | func (s *RFC5322) skipSpace() int { 249 | var skipped int 250 | for { 251 | if s.ch != ' ' && s.ch != 9 { 252 | return skipped 253 | } 254 | s.next() 255 | skipped++ 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/flashmob/go-guerrilla/backends" 8 | "github.com/flashmob/go-guerrilla/log" 9 | "io/ioutil" 10 | "time" 11 | ) 12 | 13 | // Daemon provides a convenient API when using go-guerrilla as a package in your Go project. 14 | // Is's facade for Guerrilla, AppConfig, backends.Backend and log.Logger 15 | type Daemon struct { 16 | Config *AppConfig 17 | Logger log.Logger 18 | Backend backends.Backend 19 | 20 | // Guerrilla will be managed through the API 21 | g Guerrilla 22 | 23 | configLoadTime time.Time 24 | subs []deferredSub 25 | } 26 | 27 | type deferredSub struct { 28 | topic Event 29 | fn interface{} 30 | } 31 | 32 | // AddProcessor adds a processor constructor to the backend. 33 | // name is the identifier to be used in the config. See backends docs for more info. 34 | func (d *Daemon) AddProcessor(name string, pc backends.ProcessorConstructor) { 35 | backends.Svc.AddProcessor(name, pc) 36 | } 37 | 38 | // Starts the daemon, initializing d.Config, d.Logger and d.Backend with defaults 39 | // can only be called once through the lifetime of the program 40 | func (d *Daemon) Start() (err error) { 41 | if d.g == nil { 42 | if d.Config == nil { 43 | d.Config = &AppConfig{} 44 | } 45 | if err = d.configureDefaults(); err != nil { 46 | return err 47 | } 48 | if d.Logger == nil { 49 | d.Logger, err = log.GetLogger(d.Config.LogFile, d.Config.LogLevel) 50 | if err != nil { 51 | return err 52 | } 53 | } 54 | if d.Backend == nil { 55 | d.Backend, err = backends.New(d.Config.BackendConfig, d.Logger) 56 | if err != nil { 57 | return err 58 | } 59 | } 60 | d.g, err = New(d.Config, d.Backend, d.Logger) 61 | if err != nil { 62 | return err 63 | } 64 | for i := range d.subs { 65 | _ = d.Subscribe(d.subs[i].topic, d.subs[i].fn) 66 | 67 | } 68 | d.subs = make([]deferredSub, 0) 69 | } 70 | err = d.g.Start() 71 | if err == nil { 72 | if err := d.resetLogger(); err == nil { 73 | d.Log().Infof("main log configured to %s", d.Config.LogFile) 74 | } 75 | 76 | } 77 | return err 78 | } 79 | 80 | // Shuts down the daemon, including servers and backend. 81 | // Do not call Start on it again, use a new server. 82 | func (d *Daemon) Shutdown() { 83 | if d.g != nil { 84 | d.g.Shutdown() 85 | } 86 | } 87 | 88 | // LoadConfig reads in the config from a JSON file. 89 | // Note: if d.Config is nil, the sets d.Config with the unmarshalled AppConfig which will be returned 90 | func (d *Daemon) LoadConfig(path string) (AppConfig, error) { 91 | var ac AppConfig 92 | data, err := ioutil.ReadFile(path) 93 | if err != nil { 94 | return ac, fmt.Errorf("could not read config file: %s", err.Error()) 95 | } 96 | err = ac.Load(data) 97 | if err != nil { 98 | return ac, err 99 | } 100 | if d.Config == nil { 101 | d.Config = &ac 102 | } 103 | return ac, nil 104 | } 105 | 106 | // SetConfig is same as LoadConfig, except you can pass AppConfig directly 107 | // does not emit any change events, instead use ReloadConfig after daemon has started 108 | func (d *Daemon) SetConfig(c AppConfig) error { 109 | // need to call c.Load, thus need to convert the config 110 | // d.load takes json bytes, marshal it 111 | data, err := json.Marshal(&c) 112 | if err != nil { 113 | return err 114 | } 115 | err = c.Load(data) 116 | if err != nil { 117 | return err 118 | } 119 | d.Config = &c 120 | return nil 121 | } 122 | 123 | // Reload a config using the passed in AppConfig and emit config change events 124 | func (d *Daemon) ReloadConfig(c AppConfig) error { 125 | oldConfig := *d.Config 126 | err := d.SetConfig(c) 127 | if err != nil { 128 | d.Log().WithError(err).Error("Error while reloading config") 129 | return err 130 | } 131 | d.Log().Infof("Configuration was reloaded at %s", d.configLoadTime) 132 | d.Config.EmitChangeEvents(&oldConfig, d.g) 133 | 134 | return nil 135 | } 136 | 137 | // Reload a config from a file and emit config change events 138 | func (d *Daemon) ReloadConfigFile(path string) error { 139 | ac, err := d.LoadConfig(path) 140 | if err != nil { 141 | d.Log().WithError(err).Error("Error while reloading config from file") 142 | return err 143 | } else if d.Config != nil { 144 | oldConfig := *d.Config 145 | d.Config = &ac 146 | d.Log().Infof("Configuration was reloaded at %s", d.configLoadTime) 147 | d.Config.EmitChangeEvents(&oldConfig, d.g) 148 | } 149 | return nil 150 | } 151 | 152 | // ReopenLogs send events to re-opens all log files. 153 | // Typically, one would call this after rotating logs 154 | func (d *Daemon) ReopenLogs() error { 155 | if d.Config == nil { 156 | return errors.New("d.Config nil") 157 | } 158 | d.Config.EmitLogReopenEvents(d.g) 159 | return nil 160 | } 161 | 162 | // Subscribe for subscribing to config change events 163 | func (d *Daemon) Subscribe(topic Event, fn interface{}) error { 164 | if d.g == nil { 165 | // defer the subscription until the daemon is started 166 | d.subs = append(d.subs, deferredSub{topic, fn}) 167 | return nil 168 | } 169 | return d.g.Subscribe(topic, fn) 170 | } 171 | 172 | // for publishing config change events 173 | func (d *Daemon) Publish(topic Event, args ...interface{}) { 174 | if d.g == nil { 175 | return 176 | } 177 | d.g.Publish(topic, args...) 178 | } 179 | 180 | // for unsubscribing from config change events 181 | func (d *Daemon) Unsubscribe(topic Event, handler interface{}) error { 182 | if d.g == nil { 183 | for i := range d.subs { 184 | if d.subs[i].topic == topic && d.subs[i].fn == handler { 185 | d.subs = append(d.subs[:i], d.subs[i+1:]...) 186 | } 187 | } 188 | return nil 189 | } 190 | return d.g.Unsubscribe(topic, handler) 191 | } 192 | 193 | // log returns a logger that implements our log.Logger interface. 194 | // level is set to "info" by default 195 | func (d *Daemon) Log() log.Logger { 196 | if d.Logger != nil { 197 | return d.Logger 198 | } 199 | out := log.OutputStderr.String() 200 | level := log.InfoLevel.String() 201 | if d.Config != nil { 202 | if len(d.Config.LogFile) > 0 { 203 | out = d.Config.LogFile 204 | } 205 | if len(d.Config.LogLevel) > 0 { 206 | level = d.Config.LogLevel 207 | } 208 | } 209 | l, _ := log.GetLogger(out, level) 210 | return l 211 | 212 | } 213 | 214 | // set the default values for the servers and backend config options 215 | func (d *Daemon) configureDefaults() error { 216 | err := d.Config.setDefaults() 217 | if err != nil { 218 | return err 219 | } 220 | if d.Backend == nil { 221 | err = d.Config.setBackendDefaults() 222 | if err != nil { 223 | return err 224 | } 225 | } 226 | return err 227 | } 228 | 229 | // resetLogger sets the logger to the one specified in the config. 230 | // This is because at the start, the daemon may be logging to stderr, 231 | // then attaches to the logs once the config is loaded. 232 | // This will propagate down to the servers / backend too. 233 | func (d *Daemon) resetLogger() error { 234 | l, err := log.GetLogger(d.Config.LogFile, d.Config.LogLevel) 235 | if err != nil { 236 | return err 237 | } 238 | d.Logger = l 239 | d.g.SetLogger(d.Logger) 240 | return nil 241 | } 242 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/tls" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "net/textproto" 11 | "sync" 12 | "time" 13 | 14 | "github.com/flashmob/go-guerrilla/log" 15 | "github.com/flashmob/go-guerrilla/mail" 16 | "github.com/flashmob/go-guerrilla/mail/rfc5321" 17 | "github.com/flashmob/go-guerrilla/response" 18 | ) 19 | 20 | // ClientState indicates which part of the SMTP transaction a given client is in. 21 | type ClientState int 22 | 23 | const ( 24 | // The client has connected, and is awaiting our first response 25 | ClientGreeting = iota 26 | // We have responded to the client's connection and are awaiting a command 27 | ClientCmd 28 | // We have received the sender and recipient information 29 | ClientData 30 | // We have agreed with the client to secure the connection over TLS 31 | ClientStartTLS 32 | // Server will shutdown, client to shutdown on next command turn 33 | ClientShutdown 34 | ) 35 | 36 | type client struct { 37 | *mail.Envelope 38 | ID uint64 39 | ConnectedAt time.Time 40 | KilledAt time.Time 41 | // Number of errors encountered during session with this client 42 | errors int 43 | state ClientState 44 | messagesSent int 45 | // Response to be written to the client (for debugging) 46 | response bytes.Buffer 47 | bufErr error 48 | conn net.Conn 49 | bufin *smtpBufferedReader 50 | bufout *bufio.Writer 51 | smtpReader *textproto.Reader 52 | ar *adjustableLimitedReader 53 | // guards access to conn 54 | connGuard sync.Mutex 55 | log log.Logger 56 | parser rfc5321.Parser 57 | } 58 | 59 | // NewClient allocates a new client. 60 | func NewClient(conn net.Conn, clientID uint64, logger log.Logger, envelope *mail.Pool) *client { 61 | c := &client{ 62 | conn: conn, 63 | // Envelope will be borrowed from the envelope pool 64 | // the envelope could be 'detached' from the client later when processing 65 | Envelope: envelope.Borrow(getRemoteAddr(conn), clientID), 66 | ConnectedAt: time.Now(), 67 | bufin: newSMTPBufferedReader(conn), 68 | bufout: bufio.NewWriter(conn), 69 | ID: clientID, 70 | log: logger, 71 | } 72 | 73 | // used for reading the DATA state 74 | c.smtpReader = textproto.NewReader(c.bufin.Reader) 75 | return c 76 | } 77 | 78 | // sendResponse adds a response to be written on the next turn 79 | // the response gets buffered 80 | func (c *client) sendResponse(r ...interface{}) { 81 | c.bufout.Reset(c.conn) 82 | if c.log.IsDebug() { 83 | // an additional buffer so that we can log the response in debug mode only 84 | c.response.Reset() 85 | } 86 | var out string 87 | if c.bufErr != nil { 88 | c.bufErr = nil 89 | } 90 | for _, item := range r { 91 | switch v := item.(type) { 92 | case error: 93 | out = v.Error() 94 | case fmt.Stringer: 95 | out = v.String() 96 | case string: 97 | out = v 98 | } 99 | if _, c.bufErr = c.bufout.WriteString(out); c.bufErr != nil { 100 | c.log.WithError(c.bufErr).Error("could not write to c.bufout") 101 | } 102 | if c.log.IsDebug() { 103 | c.response.WriteString(out) 104 | } 105 | if c.bufErr != nil { 106 | return 107 | } 108 | } 109 | _, c.bufErr = c.bufout.WriteString("\r\n") 110 | if c.log.IsDebug() { 111 | c.response.WriteString("\r\n") 112 | } 113 | } 114 | 115 | // resetTransaction resets the SMTP transaction, ready for the next email (doesn't disconnect) 116 | // Transaction ends on: 117 | // -HELO/EHLO/REST command 118 | // -End of DATA command 119 | // TLS handshake 120 | func (c *client) resetTransaction() { 121 | c.Envelope.ResetTransaction() 122 | } 123 | 124 | // isInTransaction returns true if the connection is inside a transaction. 125 | // A transaction starts after a MAIL command gets issued by the client. 126 | // Call resetTransaction to end the transaction 127 | func (c *client) isInTransaction() bool { 128 | if len(c.MailFrom.User) == 0 && !c.MailFrom.NullPath { 129 | return false 130 | } 131 | return true 132 | } 133 | 134 | // kill flags the connection to close on the next turn 135 | func (c *client) kill() { 136 | c.KilledAt = time.Now() 137 | } 138 | 139 | // isAlive returns true if the client is to close on the next turn 140 | func (c *client) isAlive() bool { 141 | return c.KilledAt.IsZero() 142 | } 143 | 144 | // setTimeout adjust the timeout on the connection, goroutine safe 145 | func (c *client) setTimeout(t time.Duration) (err error) { 146 | defer c.connGuard.Unlock() 147 | c.connGuard.Lock() 148 | if c.conn != nil { 149 | err = c.conn.SetDeadline(time.Now().Add(t * time.Second)) 150 | } 151 | return 152 | } 153 | 154 | // closeConn closes a client connection, , goroutine safe 155 | func (c *client) closeConn() { 156 | defer c.connGuard.Unlock() 157 | c.connGuard.Lock() 158 | _ = c.conn.Close() 159 | c.conn = nil 160 | } 161 | 162 | // init is called after the client is borrowed from the pool, to get it ready for the connection 163 | func (c *client) init(conn net.Conn, clientID uint64, ep *mail.Pool) { 164 | c.conn = conn 165 | // reset our reader & writer 166 | c.bufout.Reset(conn) 167 | c.bufin.Reset(conn) 168 | // reset session data 169 | c.state = 0 170 | c.KilledAt = time.Time{} 171 | c.ConnectedAt = time.Now() 172 | c.ID = clientID 173 | c.errors = 0 174 | // borrow an envelope from the envelope pool 175 | c.Envelope = ep.Borrow(getRemoteAddr(conn), clientID) 176 | } 177 | 178 | // getID returns the client's unique ID 179 | func (c *client) getID() uint64 { 180 | return c.ID 181 | } 182 | 183 | // UpgradeToTLS upgrades a client connection to TLS 184 | func (c *client) upgradeToTLS(tlsConfig *tls.Config) error { 185 | // wrap c.conn in a new TLS server side connection 186 | tlsConn := tls.Server(c.conn, tlsConfig) 187 | // Call handshake here to get any handshake error before reading starts 188 | err := tlsConn.Handshake() 189 | if err != nil { 190 | return err 191 | } 192 | // convert tlsConn to net.Conn 193 | c.conn = net.Conn(tlsConn) 194 | c.bufout.Reset(c.conn) 195 | c.bufin.Reset(c.conn) 196 | c.TLS = true 197 | return err 198 | } 199 | 200 | func getRemoteAddr(conn net.Conn) string { 201 | if addr, ok := conn.RemoteAddr().(*net.TCPAddr); ok { 202 | // we just want the IP (not the port) 203 | return addr.IP.String() 204 | } else { 205 | return conn.RemoteAddr().Network() 206 | } 207 | } 208 | 209 | type pathParser func([]byte) error 210 | 211 | func (c *client) parsePath(in []byte, p pathParser) (mail.Address, error) { 212 | address := mail.Address{} 213 | var err error 214 | if len(in) > rfc5321.LimitPath { 215 | return address, errors.New(response.Canned.FailPathTooLong.String()) 216 | } 217 | if err = p(in); err != nil { 218 | return address, errors.New(response.Canned.FailInvalidAddress.String()) 219 | } else if c.parser.NullPath { 220 | // bounce has empty from address 221 | address = mail.Address{} 222 | } else if len(c.parser.LocalPart) > rfc5321.LimitLocalPart { 223 | err = errors.New(response.Canned.FailLocalPartTooLong.String()) 224 | } else if len(c.parser.Domain) > rfc5321.LimitDomain { 225 | err = errors.New(response.Canned.FailDomainTooLong.String()) 226 | } else { 227 | address = mail.Address{ 228 | User: c.parser.LocalPart, 229 | Host: c.parser.Domain, 230 | ADL: c.parser.ADL, 231 | PathParams: c.parser.PathParams, 232 | NullPath: c.parser.NullPath, 233 | Quoted: c.parser.LocalPartQuotes, 234 | IP: c.parser.IP, 235 | } 236 | } 237 | return address, err 238 | } 239 | 240 | func (s *server) rcptTo() (address mail.Address, err error) { 241 | return address, err 242 | } 243 | -------------------------------------------------------------------------------- /backends/backend.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/flashmob/go-guerrilla/log" 7 | "github.com/flashmob/go-guerrilla/mail" 8 | "reflect" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "sync/atomic" 13 | ) 14 | 15 | var ( 16 | Svc *service 17 | 18 | // Store the constructor for making an new processor decorator. 19 | processors map[string]ProcessorConstructor 20 | 21 | b Backend 22 | ) 23 | 24 | func init() { 25 | Svc = &service{} 26 | processors = make(map[string]ProcessorConstructor) 27 | } 28 | 29 | type ProcessorConstructor func() Decorator 30 | 31 | // Backends process received mail. Depending on the implementation, they can store mail in the database, 32 | // write to a file, check for spam, re-transmit to another server, etc. 33 | // Must return an SMTP message (i.e. "250 OK") and a boolean indicating 34 | // whether the message was processed successfully. 35 | type Backend interface { 36 | // Process processes then saves the mail envelope 37 | Process(*mail.Envelope) Result 38 | // ValidateRcpt validates the last recipient that was pushed to the mail envelope 39 | ValidateRcpt(e *mail.Envelope) RcptError 40 | // Initializes the backend, eg. creates folders, sets-up database connections 41 | Initialize(BackendConfig) error 42 | // Initializes the backend after it was Shutdown() 43 | Reinitialize() error 44 | // Shutdown frees / closes anything created during initializations 45 | Shutdown() error 46 | // Start Starts a backend that has been initialized 47 | Start() error 48 | } 49 | 50 | type BackendConfig map[string]interface{} 51 | 52 | // All config structs extend from this 53 | type BaseConfig interface{} 54 | 55 | type notifyMsg struct { 56 | err error 57 | queuedID string 58 | result Result 59 | } 60 | 61 | // Result represents a response to an SMTP client after receiving DATA. 62 | // The String method should return an SMTP message ready to send back to the 63 | // client, for example `250 OK: Message received`. 64 | type Result interface { 65 | fmt.Stringer 66 | // Code should return the SMTP code associated with this response, ie. `250` 67 | Code() int 68 | } 69 | 70 | // Internal implementation of BackendResult for use by backend implementations. 71 | type result struct { 72 | // we're going to use a bytes.Buffer for building a string 73 | bytes.Buffer 74 | } 75 | 76 | func (r *result) String() string { 77 | return r.Buffer.String() 78 | } 79 | 80 | // Parses the SMTP code from the first 3 characters of the SMTP message. 81 | // Returns 554 if code cannot be parsed. 82 | func (r *result) Code() int { 83 | trimmed := strings.TrimSpace(r.String()) 84 | if len(trimmed) < 3 { 85 | return 554 86 | } 87 | code, err := strconv.Atoi(trimmed[:3]) 88 | if err != nil { 89 | return 554 90 | } 91 | return code 92 | } 93 | 94 | func NewResult(r ...interface{}) Result { 95 | buf := new(result) 96 | for _, item := range r { 97 | switch v := item.(type) { 98 | case error: 99 | _, _ = buf.WriteString(v.Error()) 100 | case fmt.Stringer: 101 | _, _ = buf.WriteString(v.String()) 102 | case string: 103 | _, _ = buf.WriteString(v) 104 | } 105 | } 106 | return buf 107 | } 108 | 109 | type processorInitializer interface { 110 | Initialize(backendConfig BackendConfig) error 111 | } 112 | 113 | type processorShutdowner interface { 114 | Shutdown() error 115 | } 116 | 117 | type InitializeWith func(backendConfig BackendConfig) error 118 | type ShutdownWith func() error 119 | 120 | // Satisfy ProcessorInitializer interface 121 | // So we can now pass an anonymous function that implements ProcessorInitializer 122 | func (i InitializeWith) Initialize(backendConfig BackendConfig) error { 123 | // delegate to the anonymous function 124 | return i(backendConfig) 125 | } 126 | 127 | // satisfy ProcessorShutdowner interface, same concept as InitializeWith type 128 | func (s ShutdownWith) Shutdown() error { 129 | // delegate 130 | return s() 131 | } 132 | 133 | type Errors []error 134 | 135 | // implement the Error interface 136 | func (e Errors) Error() string { 137 | if len(e) == 1 { 138 | return e[0].Error() 139 | } 140 | // multiple errors 141 | msg := "" 142 | for _, err := range e { 143 | msg += "\n" + err.Error() 144 | } 145 | return msg 146 | } 147 | 148 | func convertError(name string) error { 149 | return fmt.Errorf("failed to load backend config (%s)", name) 150 | } 151 | 152 | type service struct { 153 | initializers []processorInitializer 154 | shutdowners []processorShutdowner 155 | sync.Mutex 156 | mainlog atomic.Value 157 | } 158 | 159 | // Get loads the log.logger in an atomic operation. Returns a stderr logger if not able to load 160 | func Log() log.Logger { 161 | if v, ok := Svc.mainlog.Load().(log.Logger); ok { 162 | return v 163 | } 164 | l, _ := log.GetLogger(log.OutputStderr.String(), log.InfoLevel.String()) 165 | return l 166 | } 167 | 168 | func (s *service) SetMainlog(l log.Logger) { 169 | s.mainlog.Store(l) 170 | } 171 | 172 | // AddInitializer adds a function that implements ProcessorShutdowner to be called when initializing 173 | func (s *service) AddInitializer(i processorInitializer) { 174 | s.Lock() 175 | defer s.Unlock() 176 | s.initializers = append(s.initializers, i) 177 | } 178 | 179 | // AddShutdowner adds a function that implements ProcessorShutdowner to be called when shutting down 180 | func (s *service) AddShutdowner(sh processorShutdowner) { 181 | s.Lock() 182 | defer s.Unlock() 183 | s.shutdowners = append(s.shutdowners, sh) 184 | } 185 | 186 | // reset clears the initializers and Shutdowners 187 | func (s *service) reset() { 188 | s.shutdowners = make([]processorShutdowner, 0) 189 | s.initializers = make([]processorInitializer, 0) 190 | } 191 | 192 | // Initialize initializes all the processors one-by-one and returns any errors. 193 | // Subsequent calls to Initialize will not call the initializer again unless it failed on the previous call 194 | // so Initialize may be called again to retry after getting errors 195 | func (s *service) initialize(backend BackendConfig) Errors { 196 | s.Lock() 197 | defer s.Unlock() 198 | var errors Errors 199 | failed := make([]processorInitializer, 0) 200 | for i := range s.initializers { 201 | if err := s.initializers[i].Initialize(backend); err != nil { 202 | errors = append(errors, err) 203 | failed = append(failed, s.initializers[i]) 204 | } 205 | } 206 | // keep only the failed initializers 207 | s.initializers = failed 208 | return errors 209 | } 210 | 211 | // Shutdown shuts down all the processors by calling their shutdowners (if any) 212 | // Subsequent calls to Shutdown will not call the shutdowners again unless it failed on the previous call 213 | // so Shutdown may be called again to retry after getting errors 214 | func (s *service) shutdown() Errors { 215 | s.Lock() 216 | defer s.Unlock() 217 | var errors Errors 218 | failed := make([]processorShutdowner, 0) 219 | for i := range s.shutdowners { 220 | if err := s.shutdowners[i].Shutdown(); err != nil { 221 | errors = append(errors, err) 222 | failed = append(failed, s.shutdowners[i]) 223 | } 224 | } 225 | s.shutdowners = failed 226 | return errors 227 | } 228 | 229 | // AddProcessor adds a new processor, which becomes available to the backend_config.save_process option 230 | // and also the backend_config.validate_process option 231 | // Use to add your own custom processor when using backends as a package, or after importing an external 232 | // processor. 233 | func (s *service) AddProcessor(name string, p ProcessorConstructor) { 234 | // wrap in a constructor since we want to defer calling it 235 | var c ProcessorConstructor 236 | c = func() Decorator { 237 | return p() 238 | } 239 | // add to our processors list 240 | processors[strings.ToLower(name)] = c 241 | } 242 | 243 | // extractConfig loads the backend config. It has already been unmarshalled 244 | // configData contains data from the main config file's "backend_config" value 245 | // configType is a Processor's specific config value. 246 | // The reason why using reflection is because we'll get a nice error message if the field is missing 247 | // the alternative solution would be to json.Marshal() and json.Unmarshal() however that will not give us any 248 | // error messages 249 | func (s *service) ExtractConfig(configData BackendConfig, configType BaseConfig) (interface{}, error) { 250 | // Use reflection so that we can provide a nice error message 251 | v := reflect.ValueOf(configType).Elem() // so that we can set the values 252 | //m := reflect.ValueOf(configType).Elem() 253 | t := reflect.TypeOf(configType).Elem() 254 | typeOfT := v.Type() 255 | 256 | for i := 0; i < v.NumField(); i++ { 257 | f := v.Field(i) 258 | // read the tags of the config struct 259 | fieldName := t.Field(i).Tag.Get("json") 260 | omitempty := false 261 | if len(fieldName) > 0 { 262 | // parse the tag to 263 | // get the field name from struct tag 264 | split := strings.Split(fieldName, ",") 265 | fieldName = split[0] 266 | if len(split) > 1 { 267 | if split[1] == "omitempty" { 268 | omitempty = true 269 | } 270 | } 271 | } else { 272 | // could have no tag 273 | // so use the reflected field name 274 | fieldName = typeOfT.Field(i).Name 275 | } 276 | if f.Type().Name() == "int" { 277 | // in json, there is no int, only floats... 278 | if intVal, converted := configData[fieldName].(float64); converted { 279 | v.Field(i).SetInt(int64(intVal)) 280 | } else if intVal, converted := configData[fieldName].(int); converted { 281 | v.Field(i).SetInt(int64(intVal)) 282 | } else if !omitempty { 283 | return configType, convertError("property missing/invalid: '" + fieldName + "' of expected type: " + f.Type().Name()) 284 | } 285 | } 286 | if f.Type().Name() == "string" { 287 | if stringVal, converted := configData[fieldName].(string); converted { 288 | v.Field(i).SetString(stringVal) 289 | } else if !omitempty { 290 | return configType, convertError("missing/invalid: '" + fieldName + "' of type: " + f.Type().Name()) 291 | } 292 | } 293 | if f.Type().Name() == "bool" { 294 | if boolVal, converted := configData[fieldName].(bool); converted { 295 | v.Field(i).SetBool(boolVal) 296 | } else if !omitempty { 297 | return configType, convertError("missing/invalid: '" + fieldName + "' of type: " + f.Type().Name()) 298 | } 299 | } 300 | } 301 | return configType, nil 302 | } 303 | -------------------------------------------------------------------------------- /response/quote.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | // This is an easter egg 9 | 10 | const CRLF = "\r\n" 11 | 12 | var quotes = struct { 13 | m map[int]string 14 | }{m: map[int]string{ 15 | 0: "214 Maude Lebowski: He's a good man....and thorough.", 16 | 1: "214 The Dude: I had a rough night and I hate the f***ing Eagles, man.", 17 | 2: "214 Walter Sobchak: The chinaman is not the issue here... also dude, Asian American please", 18 | 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?", 19 | 4: "214 The Dude: Hey, I know that guy, he's a nihilist. Karl Hungus.", 20 | 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 + 21 | "214 The Dude: Mr. Treehorn treats objects like women, man.", 22 | 6: "214 Walter Sobchak: Shut the f**k up, Donny!", 23 | 7: "214-Donny: Shut the f**k up, Donny!" + CRLF + 24 | "214 Walter Sobchak: Shut the f**k up, Donny!", 25 | 8: "214 The Dude: It really tied the room together.", 26 | 9: "214 Walter Sobchak: Is this your homework, Larry?", 27 | 10: "214 The Dude: Who the f**k are the Knutsens?", 28 | 11: "214 The Dude: Yeah,well, that's just, like, your opinion, man.", 29 | 12: "214-Walter Sobchak: Am I the only one who gives a s**t about the rules?!" + CRLF + 30 | "214 Walter Sobchak: Am I the only one who gives a s**t about the rules?", 31 | 13: "214-Walter Sobchak: Am I wrong?" + CRLF + 32 | "214-The Dude: No, you're not wrong Walter, you're just an ass-hole." + 33 | "214 Walter Sobchak: Okay then.", 34 | 14: "214-Private Snoop: you see what happens lebowski?" + CRLF + 35 | "214-The Dude: nobody calls me lebowski, you got the wrong guy, I'm the dude, man." + CRLF + 36 | "214-Private Snoop: Your name's Lebowski, Lebowski. Your wife is Bunny." + CRLF + 37 | "214-The Dude: My wife? Bunny? Do you see a wedding ring on my finger? " + CRLF + 38 | "214 Does this place look like I'm f**kin married? The toilet seat's up man!", 39 | 15: "214-The Dude: Yeah man. it really tied the room together." + CRLF + 40 | "214-Donny: What tied the room together dude?" + CRLF + 41 | "214-The Dude: My rug." + CRLF + 42 | "214-Walter Sobchak: Were you listening to the Dude's story, Donny?" + CRLF + 43 | "214-Donny: I was bowling." + CRLF + 44 | "214-Walter Sobchak: So then you have no frame of reference here, Donny, " + CRLF + 45 | "214 You're like a child who wonders in the middle of movie.", 46 | 16: "214-The Dude: She probably kidnapped herself." + CRLF + 47 | "214-Donny: What do you mean dude?" + CRLF + 48 | "214-The Dude: Rug Peers did not do this. look at it. " + CRLF + 49 | "214-A young trophy wife, marries this guy for his money, she figures he " + CRLF + 50 | "214-hasn't given her enough, she owes money all over town." + CRLF + 51 | "214 Walter Sobchak: That f**kin bitch.", 52 | 17: "214 Walter Sobchak: Forget it, Donny, you're out of your element!", 53 | 18: "214-Walter Sobchak: You want a toe? I can get you a toe, believe me." + CRLF + 54 | "214-There are ways, Dude. You don't wanna know about it, believe me. " + CRLF + 55 | "214-The Dude: Yeah, but Walter." + 56 | "214 Walter Sobchak: Hell, I can get you a toe by 3 o'clock this afternoon with nail polish.", 57 | 19: "214 Walter Sobchak: Calmer then you are.", 58 | 20: "214 Walter Sobchak: You are entering a world of pain", 59 | 21: "214 The Dude: This aggression will not stand man.", 60 | 22: "214 The Dude: His dudeness, duder, or el dudorino", 61 | 23: "214 Walter Sobchak: Has the whole world gone crazy!", 62 | 24: "214 Walter Sobchak: Calm down your being very undude.", 63 | 25: "214-Donny: Are these the Nazis, Walter?" + CRLF + 64 | "214 Walter Sobchak: No Donny, these men are nihilists. There's nothing to be afraid of.", 65 | 26: "214 Walter Sobchak: Well, it was parked in the handicapped zone. Perhaps they towed it.", 66 | 27: "214-Private Snoop: I'm a brother shamus!" + CRLF + 67 | "214 The Dude: Brother Seamus? Like an Irish monk?", 68 | 28: "214 Walter Sobchak: Have you ever of Vietnam? You're about to enter a world of pain!", 69 | 29: "214-Donny: What's a pederast, Walter?" + CRLF + 70 | "214 Walter Sobchak: Shut the f**k up, Donny.", 71 | 30: "214 The Dude: Hey, careful, man, there's a beverage here!", 72 | 31: "214 The Stranger: Sometimes you eat the bar and sometimes, well, the bar eats you.", 73 | 32: "214 Walter Sobchak: Goodnight, sweet prince.", 74 | 33: "214 Jackie Treehorn: People forget the brain is the biggest erogenous zone.", 75 | 34: "214-The Big Lebowski: What makes a man? Is it doing the right thing?" + CRLF + 76 | "214 The Dude: Sure, that and a pair of testicles.", 77 | 35: "214 The Dude: At least I'm housebroken.", 78 | 36: "214-Walter Sobchak: Three thousand years of beautiful tradition, from Moses to Sandy Koufax." + CRLF + 79 | "214 You're goddamn right I'm living in the f**king past!", 80 | 37: "214-The Stranger: There's just one thing, dude." + CRLF + 81 | "214-The Dude: What's that?" + CRLF + 82 | "214-The Stranger: Do you have to use so many cuss words?" + CRLF + 83 | "214 The Dude: What the f**k you talkin' about?", 84 | 38: "214-Walter Sobchak: I mean, say what you want about the tenets of National Socialism, " + CRLF + 85 | "214 Dude, at least it's an ethos.", 86 | 39: "214 The Dude: My only hope is that the Big Lebowski kills me before the Germans can cut my d**k off.", 87 | 40: "214 The Dude: You human paraquat!", 88 | 41: "214 The Dude: Strikes and gutters, ups and downs.", 89 | 42: "214 The Dude: Sooner or later you are going to have to face the fact that your a moron.", 90 | 43: "214-The Dude: The fixes the cable?" + CRLF + 91 | "214 Maude Lebowski: Don't be fatuous Jerry.", 92 | 44: "214 The Dude: Yeah, well, that's just, like, your opinion, man.", 93 | 45: "214 The Dude: I don't need your sympathy, I need my Johnson.", 94 | 46: "214 Donny: I am the walrus.", 95 | 47: "214 The Dude: We f**ked it up!", 96 | 48: "214 Jesus Quintana: You got that right, NO ONE f**ks with the jesus.", 97 | 49: "214 Walter Sobchak: You can say what you want about the tenets of national socialism but at least it's an ethos.", 98 | 50: "214-Walter Sobchak: f**king Germans. Nothing changes. f**king Nazis." + CRLF + 99 | "214-Donny: They were Nazis, Dude?" + CRLF + 100 | "214 Walter Sobchak: Oh, come on Donny, they were threatening castration! Are we gonna split hairs here? Am I wrong?", 101 | 51: "214 Walter Sobchak: [pulls out a gun] Smokey, my friend, you are entering a world of pain.", 102 | 52: "214 Walter Sobchak: This is what happens when you f**k a stranger in the ass!", 103 | 53: "214-The Dude: We dropped off the money." + CRLF + 104 | "214-The Big Lebowski: *We*!?" + CRLF + 105 | "214 The Dude: *I*; the royal we.", 106 | 54: "214 Walter Sobchak: You see what happens larry when you f**k a stranger in the ass.", 107 | 55: "214 The Dude: The Dude abides.", 108 | 56: "214 Walter Sobchak: f**k it dude, lets go bowling.", 109 | 57: "214 The Dude: I can't be worrying about that s**t. Life goes on, man.", 110 | 58: "214 Walter Sobchak: The ringer cannot look empty.", 111 | 59: "214-Malibu Police Chief: I don't like your jerk-off name, I don't like your jerk-off face," + CRLF + 112 | "214 I don't like your j3rk-off behavior, and I don't like you... j3rk-off.", 113 | 60: "214-Walter Sobchak: Has the whole world gone CRAZY? Am I the only one around here who gives" + CRLF + 114 | "214 a s**t about the rules? You think I'm f**kin' around, MARK IT ZERO!", 115 | 61: "214 Walter Sobchak: Look, Larry. Have you ever heard of Vietnam?", 116 | 62: "214 The Dude: Ha hey, this is a private residence man.", 117 | 63: "214 The Dude: Obviously you're not a golfer.", 118 | 64: "214 Walter Sobchak: You know, Dude, I myself dabbled in pacifism once. Not in Nam, of course.", 119 | 65: "214 Walter Sobchak: Donny, you're out of your element!", 120 | 66: "214 The Dude: Another caucasian, Gary.", 121 | 67: "214-Bunny Lebowski: I'll s**k your c**k for a thousand dollars." + CRLF + 122 | "214-Brandt: Ah... Ha... ha... HA! Yes, we're all very fond of her." + CRLF + 123 | "214-Bunny Lebowski: Brandt can't watch, though. Or it's an extra hundred." + CRLF + 124 | "214 The Dude: Okay... just give me a minute. I gotta go find a cash machine...", 125 | 68: "214 Nihilist: Ve vont ze mawney Lebowski!", 126 | 69: "214 Walter Sobchak: Eight-year-olds, dude.", 127 | 70: "214-The Dude: They peed on my rug, man!" + CRLF + 128 | "214-Walter Sobchak: f**king Nazis." + CRLF + 129 | "214-Donny: I don't know if they were Nazis, Walter..." + CRLF + 130 | "214 Walter Sobchak: Shut the f**k up, Donny. They were threatening castration!", 131 | 71: "214 Jesus Quintana: I don't f**king care, it don't matter to Jesus.", 132 | 72: "214-The Dude: Where's my car?" + CRLF + 133 | "214 Walter Sobchak: It was parked in a handicap zone, perhaps they towed it.", 134 | 73: "214-Bunny Lebowski: Uli doesn't care about anything. He's a Nihilist!" + CRLF + 135 | "214 The Dude: Ah, that must be exhausting!", 136 | 74: "214 Walter Sobchak: Smoky this is not Nam this is Bowling there are rules.", 137 | 75: "214 Maude Lebowski: Vagina.", 138 | 76: "214-Jesus Quintana: Let me tell you something pendejo. You pull any of your crazy s**t with us." + CRLF + 139 | "214-You flash your piece out on the lanes. I'll take it away from you and stick up your alps" + CRLF + 140 | "214-and pull the f**king trigger 'til it goes click." + CRLF + 141 | "214-The Dude: ...Jesus" + CRLF + 142 | "214 Jesus Quintana: You said it man, nobody f**ks with the Jesus.", 143 | 77: "214-The Dude: You brought a f**king Pomeranian bowling?" + CRLF + 144 | "214-Walter Sobchak: Bought it bowling? I didn't rent it shoes. " + CRLF + 145 | "214 I'm not buying it a f**king beer. It's not taking your f**king turn, Dude.", 146 | 78: "214 Walter Sobchak: Mark it as a zero.", 147 | 79: "214-The Stranger: The Dude abides. I don't know about you, but I take comfort in that. " + CRLF + 148 | "214 It's good knowing he's out there, the Dude, takin' 'er easy for all us sinners.", 149 | 80: "214 Walter Sobchak: Aw, f**k it Dude. Let's go bowling.", 150 | 81: "214 Walter Sobchak: Life does not stop and start at your convenience you miserable piece of s**t.", 151 | 82: "214 Walter Sobchak: I told that kraut a f**king thousand times that I don't roll on Shabbos!", 152 | 83: "214 Walter Sobchak: This is what happens when you find a stranger in the alps!", 153 | }} 154 | 155 | // GetQuote returns a random quote from The big Lebowski 156 | func GetQuote() string { 157 | rand.Seed(time.Now().UnixNano()) 158 | return quotes.m[rand.Intn(len(quotes.m))] 159 | } 160 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package guerrilla 2 | 3 | import ( 4 | "github.com/flashmob/go-guerrilla/backends" 5 | "github.com/flashmob/go-guerrilla/log" 6 | "github.com/flashmob/go-guerrilla/tests/testcert" 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | // a configuration file with a dummy backend 15 | 16 | // 17 | var configJsonA = ` 18 | { 19 | "log_file" : "./tests/testlog", 20 | "log_level" : "debug", 21 | "pid_file" : "tests/go-guerrilla.pid", 22 | "allowed_hosts": ["spam4.me","grr.la"], 23 | "backend_config" : 24 | { 25 | "log_received_mails" : true 26 | }, 27 | "servers" : [ 28 | { 29 | "is_enabled" : true, 30 | "host_name":"mail.guerrillamail.com", 31 | "max_size": 100017, 32 | "timeout":160, 33 | "listen_interface":"127.0.0.1:2526", 34 | "max_clients": 2, 35 | "tls" : { 36 | "start_tls_on":false, 37 | "tls_always_on":false, 38 | "private_key_file":"config_test.go", 39 | "public_key_file":"config_test.go" 40 | } 41 | }, 42 | { 43 | "is_enabled" : true, 44 | "host_name":"mail2.guerrillamail.com", 45 | "max_size":1000001, 46 | "timeout":180, 47 | "listen_interface":"127.0.0.1:2527", 48 | "max_clients":1, 49 | "tls" : { 50 | "private_key_file":"./tests/mail2.guerrillamail.com.key.pem", 51 | "public_key_file":"./tests/mail2.guerrillamail.com.cert.pem", 52 | "tls_always_on":false, 53 | "start_tls_on":true 54 | } 55 | }, 56 | 57 | { 58 | "is_enabled" : true, 59 | "host_name":"mail.stopme.com", 60 | "max_size": 100017, 61 | "timeout":160, 62 | "listen_interface":"127.0.0.1:9999", 63 | "max_clients": 2, 64 | "tls" : { 65 | "private_key_file":"config_test.go", 66 | "public_key_file":"config_test.go", 67 | "start_tls_on":false, 68 | "tls_always_on":false 69 | } 70 | }, 71 | { 72 | "is_enabled" : true, 73 | "host_name":"mail.disableme.com", 74 | "max_size": 100017, 75 | "timeout":160, 76 | "listen_interface":"127.0.0.1:3333", 77 | "max_clients": 2, 78 | "tls" : { 79 | "private_key_file":"config_test.go", 80 | "public_key_file":"config_test.go", 81 | "start_tls_on":false, 82 | "tls_always_on":false 83 | } 84 | } 85 | ] 86 | } 87 | ` 88 | 89 | // B is A's configuration with different values from B 90 | // 127.0.0.1:4654 will be added 91 | // A's 127.0.0.1:3333 is disabled 92 | // B's 127.0.0.1:9999 is removed 93 | 94 | var configJsonB = ` 95 | { 96 | "log_file" : "./tests/testlog", 97 | "log_level" : "debug", 98 | "pid_file" : "tests/different-go-guerrilla.pid", 99 | "allowed_hosts": ["spam4.me","grr.la","newhost.com"], 100 | "backend_config" : 101 | { 102 | "log_received_mails" : true 103 | }, 104 | "servers" : [ 105 | { 106 | "is_enabled" : true, 107 | "host_name":"mail.guerrillamail.com", 108 | "max_size": 100017, 109 | "timeout":161, 110 | "listen_interface":"127.0.0.1:2526", 111 | "max_clients": 3, 112 | "tls" : { 113 | "private_key_file":"./tests/mail2.guerrillamail.com.key.pem", 114 | "public_key_file": "./tests/mail2.guerrillamail.com.cert.pem", 115 | "start_tls_on":false, 116 | "tls_always_on":true 117 | } 118 | }, 119 | { 120 | "is_enabled" : true, 121 | "host_name":"mail2.guerrillamail.com", 122 | "max_size": 100017, 123 | "timeout":160, 124 | "listen_interface":"127.0.0.1:2527", 125 | "log_file" : "./tests/testlog", 126 | "max_clients": 2, 127 | "tls" : { 128 | "private_key_file":"./tests/mail2.guerrillamail.com.key.pem", 129 | "public_key_file": "./tests/mail2.guerrillamail.com.cert.pem", 130 | "start_tls_on":true, 131 | "tls_always_on":false 132 | } 133 | }, 134 | 135 | { 136 | "is_enabled" : true, 137 | "host_name":"mail.guerrillamail.com", 138 | "max_size":1000001, 139 | "timeout":180, 140 | "listen_interface":"127.0.0.1:4654", 141 | "max_clients":1, 142 | "tls" : { 143 | "private_key_file":"config_test.go", 144 | "public_key_file":"config_test.go", 145 | "start_tls_on":false, 146 | "tls_always_on":false 147 | } 148 | }, 149 | 150 | { 151 | "is_enabled" : false, 152 | "host_name":"mail.disbaleme.com", 153 | "max_size": 100017, 154 | "timeout":160, 155 | "listen_interface":"127.0.0.1:3333", 156 | "max_clients": 2, 157 | "tls" : { 158 | "private_key_file":"config_test.go", 159 | "public_key_file":"config_test.go", 160 | "start_tls_on":false, 161 | "tls_always_on":false 162 | } 163 | } 164 | ] 165 | } 166 | ` 167 | 168 | func TestConfigLoad(t *testing.T) { 169 | if err := testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "./tests/"); err != nil { 170 | t.Error(err) 171 | } 172 | defer func() { 173 | if err := deleteIfExists("../tests/mail2.guerrillamail.com.cert.pem"); err != nil { 174 | t.Error(err) 175 | } 176 | if err := deleteIfExists("../tests/mail2.guerrillamail.com.key.pem"); err != nil { 177 | t.Error(err) 178 | } 179 | }() 180 | 181 | ac := &AppConfig{} 182 | if err := ac.Load([]byte(configJsonA)); err != nil { 183 | t.Error("Cannot load config |", err) 184 | t.SkipNow() 185 | } 186 | expectedLen := 4 187 | if len(ac.Servers) != expectedLen { 188 | t.Error("len(ac.Servers), expected", expectedLen, "got", len(ac.Servers)) 189 | t.SkipNow() 190 | } 191 | // did we got the timestamps? 192 | if ac.Servers[0].TLS._privateKeyFileMtime <= 0 { 193 | t.Error("failed to read timestamp for _privateKeyFileMtime, got", ac.Servers[0].TLS._privateKeyFileMtime) 194 | } 195 | } 196 | 197 | // Test the sample config to make sure a valid one is given! 198 | func TestSampleConfig(t *testing.T) { 199 | fileName := "goguerrilla.conf.sample" 200 | if jsonBytes, err := ioutil.ReadFile(fileName); err == nil { 201 | ac := &AppConfig{} 202 | if err := ac.Load(jsonBytes); err != nil { 203 | // sample config can have broken tls certs 204 | if strings.Index(err.Error(), "cannot use TLS config for [127.0.0.1:25") != 0 { 205 | t.Error("Cannot load config", fileName, "|", err) 206 | t.FailNow() 207 | } 208 | } 209 | } else { 210 | t.Error("Error reading", fileName, "|", err) 211 | } 212 | } 213 | 214 | // make sure that we get all the config change events 215 | func TestConfigChangeEvents(t *testing.T) { 216 | if err := testcert.GenerateCert("mail2.guerrillamail.com", "", 365*24*time.Hour, false, 2048, "P256", "./tests/"); err != nil { 217 | t.Error(err) 218 | } 219 | defer func() { 220 | if err := deleteIfExists("../tests/mail2.guerrillamail.com.cert.pem"); err != nil { 221 | t.Error(err) 222 | } 223 | if err := deleteIfExists("../tests/mail2.guerrillamail.com.key.pem"); err != nil { 224 | t.Error(err) 225 | } 226 | }() 227 | 228 | oldconf := &AppConfig{} 229 | if err := oldconf.Load([]byte(configJsonA)); err != nil { 230 | t.Error(err) 231 | } 232 | logger, _ := log.GetLogger(oldconf.LogFile, oldconf.LogLevel) 233 | bcfg := backends.BackendConfig{"log_received_mails": true} 234 | backend, err := backends.New(bcfg, logger) 235 | if err != nil { 236 | t.Error("cannot create backend", err) 237 | } 238 | app, err := New(oldconf, backend, logger) 239 | if err != nil { 240 | t.Error("cannot create daemon", err) 241 | } 242 | // simulate timestamp change 243 | 244 | time.Sleep(time.Second + time.Millisecond*500) 245 | if err := os.Chtimes(oldconf.Servers[1].TLS.PrivateKeyFile, time.Now(), time.Now()); err != nil { 246 | t.Error(err) 247 | } 248 | if err := os.Chtimes(oldconf.Servers[1].TLS.PublicKeyFile, time.Now(), time.Now()); err != nil { 249 | t.Error(err) 250 | } 251 | newconf := &AppConfig{} 252 | if err := newconf.Load([]byte(configJsonB)); err != nil { 253 | t.Error(err) 254 | } 255 | newconf.Servers[0].LogFile = log.OutputOff.String() // test for log file change 256 | newconf.LogLevel = log.InfoLevel.String() 257 | newconf.LogFile = "off" 258 | expectedEvents := map[Event]bool{ 259 | EventConfigPidFile: false, 260 | EventConfigLogFile: false, 261 | EventConfigLogLevel: false, 262 | EventConfigAllowedHosts: false, 263 | EventConfigServerNew: false, // 127.0.0.1:4654 will be added 264 | EventConfigServerRemove: false, // 127.0.0.1:9999 server removed 265 | EventConfigServerStop: false, // 127.0.0.1:3333: server (disabled) 266 | EventConfigServerLogFile: false, // 127.0.0.1:2526 267 | EventConfigServerLogReopen: false, // 127.0.0.1:2527 268 | EventConfigServerTimeout: false, // 127.0.0.1:2526 timeout 269 | //"server_change:tls_config": false, // 127.0.0.1:2526 270 | EventConfigServerMaxClients: false, // 127.0.0.1:2526 271 | EventConfigServerTLSConfig: false, // 127.0.0.1:2527 timestamp changed on certificates 272 | } 273 | toUnsubscribe := map[Event]func(c *AppConfig){} 274 | toUnsubscribeSrv := map[Event]func(c *ServerConfig){} 275 | 276 | for event := range expectedEvents { 277 | // Put in anon func since range is overwriting event 278 | func(e Event) { 279 | if strings.Contains(e.String(), "config_change") { 280 | f := func(c *AppConfig) { 281 | expectedEvents[e] = true 282 | } 283 | _ = app.Subscribe(event, f) 284 | toUnsubscribe[event] = f 285 | } else { 286 | // must be a server config change then 287 | f := func(c *ServerConfig) { 288 | expectedEvents[e] = true 289 | } 290 | _ = app.Subscribe(event, f) 291 | toUnsubscribeSrv[event] = f 292 | } 293 | 294 | }(event) 295 | } 296 | 297 | // emit events 298 | newconf.EmitChangeEvents(oldconf, app) 299 | // unsubscribe 300 | for unevent, unfun := range toUnsubscribe { 301 | _ = app.Unsubscribe(unevent, unfun) 302 | } 303 | for unevent, unfun := range toUnsubscribeSrv { 304 | _ = app.Unsubscribe(unevent, unfun) 305 | } 306 | for event, val := range expectedEvents { 307 | if val == false { 308 | t.Error("Did not fire config change event:", event) 309 | t.FailNow() 310 | } 311 | } 312 | 313 | // don't forget to reset 314 | if err := os.Truncate(oldconf.LogFile, 0); err != nil { 315 | t.Error(err) 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /backends/p_sql.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/flashmob/go-guerrilla/mail" 10 | 11 | "math/big" 12 | "net" 13 | "runtime/debug" 14 | 15 | "github.com/flashmob/go-guerrilla/response" 16 | ) 17 | 18 | // ---------------------------------------------------------------------------------- 19 | // Processor Name: sql 20 | // ---------------------------------------------------------------------------------- 21 | // Description : Saves the e.Data (email data) and e.DeliveryHeader together in sql 22 | // : using the hash generated by the "hash" processor and stored in 23 | // : e.Hashes 24 | // ---------------------------------------------------------------------------------- 25 | // Config Options: mail_table string - name of table for storing emails 26 | // : sql_driver string - database driver name, eg. mysql 27 | // : sql_dsn string - driver-specific data source name 28 | // : primary_mail_host string - primary host name 29 | // : sql_max_open_conns - sets the maximum number of open connections 30 | // : to the database. The default is 0 (unlimited) 31 | // : sql_max_idle_conns - sets the maximum number of connections in the 32 | // : idle connection pool. The default is 2 33 | // : sql_max_conn_lifetime - sets the maximum amount of time 34 | // : a connection may be reused 35 | // --------------:------------------------------------------------------------------- 36 | // Input : e.Data 37 | // : e.DeliveryHeader generated by ParseHeader() processor 38 | // : e.MailFrom 39 | // : e.Subject - generated by by ParseHeader() processor 40 | // ---------------------------------------------------------------------------------- 41 | // Output : Sets e.QueuedId with the first item fromHashes[0] 42 | // ---------------------------------------------------------------------------------- 43 | func init() { 44 | processors["sql"] = func() Decorator { 45 | return SQL() 46 | } 47 | } 48 | 49 | type SQLProcessorConfig struct { 50 | Table string `json:"mail_table"` 51 | Driver string `json:"sql_driver"` 52 | DSN string `json:"sql_dsn"` 53 | SQLInsert string `json:"sql_insert,omitempty"` 54 | SQLValues string `json:"sql_values,omitempty"` 55 | PrimaryHost string `json:"primary_mail_host"` 56 | MaxConnLifetime string `json:"sql_max_conn_lifetime,omitempty"` 57 | MaxOpenConns int `json:"sql_max_open_conns,omitempty"` 58 | MaxIdleConns int `json:"sql_max_idle_conns,omitempty"` 59 | } 60 | 61 | type SQLProcessor struct { 62 | cache stmtCache 63 | config *SQLProcessorConfig 64 | } 65 | 66 | func (s *SQLProcessor) connect() (*sql.DB, error) { 67 | var db *sql.DB 68 | var err error 69 | if db, err = sql.Open(s.config.Driver, s.config.DSN); err != nil { 70 | Log().Error("cannot open database: ", err) 71 | return nil, err 72 | } 73 | 74 | if s.config.MaxOpenConns != 0 { 75 | db.SetMaxOpenConns(s.config.MaxOpenConns) 76 | } 77 | if s.config.MaxIdleConns != 0 { 78 | db.SetMaxIdleConns(s.config.MaxIdleConns) 79 | } 80 | if s.config.MaxConnLifetime != "" { 81 | t, err := time.ParseDuration(s.config.MaxConnLifetime) 82 | if err != nil { 83 | return nil, err 84 | } 85 | db.SetConnMaxLifetime(t) 86 | } 87 | 88 | // do we have permission to access the table? 89 | _, err = db.Query("SELECT mail_id FROM " + s.config.Table + " LIMIT 1") 90 | if err != nil { 91 | return nil, err 92 | } 93 | return db, err 94 | } 95 | 96 | // prepares the sql query with the number of rows that can be batched with it 97 | func (s *SQLProcessor) prepareInsertQuery(rows int, db *sql.DB) *sql.Stmt { 98 | var sqlstr, values string 99 | if rows == 0 { 100 | panic("rows argument cannot be 0") 101 | } 102 | if s.cache[rows-1] != nil { 103 | return s.cache[rows-1] 104 | } 105 | if s.config.SQLInsert != "" { 106 | sqlstr = s.config.SQLInsert 107 | if !strings.HasSuffix(sqlstr, " ") { 108 | // Add a trailing space so we can concatinate our values string 109 | // without causing a syntax error 110 | sqlstr = sqlstr + " " 111 | } 112 | } else { 113 | // Default to MySQL SQL 114 | sqlstr = "INSERT INTO " + s.config.Table + " " 115 | sqlstr += "(`date`, `to`, `from`, `subject`, `body`, `mail`, `spam_score`, " 116 | sqlstr += "`hash`, `content_type`, `recipient`, `has_attach`, `ip_addr`, " 117 | sqlstr += "`return_path`, `is_tls`, `message_id`, `reply_to`, `sender`)" 118 | sqlstr += " VALUES " 119 | } 120 | if s.config.SQLValues != "" { 121 | values = s.config.SQLValues 122 | } else { 123 | values = "(NOW(), ?, ?, ?, ? , ?, 0, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)" 124 | } 125 | // add more rows 126 | comma := "" 127 | for i := 0; i < rows; i++ { 128 | sqlstr += comma + values 129 | if comma == "" { 130 | comma = "," 131 | } 132 | } 133 | stmt, sqlErr := db.Prepare(sqlstr) 134 | if sqlErr != nil { 135 | Log().WithError(sqlErr).Panic("failed while db.Prepare(INSERT...)") 136 | } 137 | // cache it 138 | s.cache[rows-1] = stmt 139 | return stmt 140 | } 141 | 142 | func (s *SQLProcessor) doQuery(c int, db *sql.DB, insertStmt *sql.Stmt, vals *[]interface{}) (execErr error) { 143 | defer func() { 144 | if r := recover(); r != nil { 145 | Log().Error("Recovered form panic:", r, string(debug.Stack())) 146 | sum := 0 147 | for _, v := range *vals { 148 | if str, ok := v.(string); ok { 149 | sum = sum + len(str) 150 | } 151 | } 152 | Log().Errorf("panic while inserting query [%s] size:%d, err %v", r, sum, execErr) 153 | panic("query failed") 154 | } 155 | }() 156 | // prepare the query used to insert when rows reaches batchMax 157 | insertStmt = s.prepareInsertQuery(c, db) 158 | _, execErr = insertStmt.Exec(*vals...) 159 | if execErr != nil { 160 | Log().WithError(execErr).Error("There was a problem the insert") 161 | } 162 | return 163 | } 164 | 165 | // for storing ip addresses in the ip_addr column 166 | func (s *SQLProcessor) ip2bint(ip string) *big.Int { 167 | bint := big.NewInt(0) 168 | addr := net.ParseIP(ip) 169 | if strings.Index(ip, "::") > 0 { 170 | bint.SetBytes(addr.To16()) 171 | } else { 172 | bint.SetBytes(addr.To4()) 173 | } 174 | return bint 175 | } 176 | 177 | func (s *SQLProcessor) fillAddressFromHeader(e *mail.Envelope, headerKey string) string { 178 | if v, ok := e.Header[headerKey]; ok { 179 | addr, err := mail.NewAddress(v[0]) 180 | if err != nil { 181 | return "" 182 | } 183 | return addr.String() 184 | } 185 | return "" 186 | } 187 | 188 | func SQL() Decorator { 189 | var config *SQLProcessorConfig 190 | var vals []interface{} 191 | var db *sql.DB 192 | s := &SQLProcessor{} 193 | 194 | // open the database connection (it will also check if we can select the table) 195 | Svc.AddInitializer(InitializeWith(func(backendConfig BackendConfig) error { 196 | configType := BaseConfig(&SQLProcessorConfig{}) 197 | bcfg, err := Svc.ExtractConfig(backendConfig, configType) 198 | if err != nil { 199 | return err 200 | } 201 | config = bcfg.(*SQLProcessorConfig) 202 | s.config = config 203 | db, err = s.connect() 204 | if err != nil { 205 | return err 206 | } 207 | return nil 208 | })) 209 | 210 | // shutdown will close the database connection 211 | Svc.AddShutdowner(ShutdownWith(func() error { 212 | if db != nil { 213 | return db.Close() 214 | } 215 | return nil 216 | })) 217 | 218 | return func(p Processor) Processor { 219 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 220 | 221 | if task == TaskSaveMail { 222 | var to, body string 223 | 224 | hash := "" 225 | if len(e.Hashes) > 0 { 226 | hash = e.Hashes[0] 227 | e.QueuedId = e.Hashes[0] 228 | } 229 | 230 | var co *DataCompressor 231 | // a compressor was set by the Compress processor 232 | if c, ok := e.Values["zlib-compressor"]; ok { 233 | body = "gzip" 234 | co = c.(*DataCompressor) 235 | } 236 | // was saved in redis by the Redis processor 237 | if _, ok := e.Values["redis"]; ok { 238 | body = "redis" 239 | } 240 | 241 | for i := range e.RcptTo { 242 | 243 | // use the To header, otherwise rcpt to 244 | to = trimToLimit(s.fillAddressFromHeader(e, "To"), 255) 245 | if to == "" { 246 | // trimToLimit(strings.TrimSpace(e.RcptTo[i].User)+"@"+config.PrimaryHost, 255) 247 | to = trimToLimit(strings.TrimSpace(e.RcptTo[i].String()), 255) 248 | } 249 | mid := trimToLimit(s.fillAddressFromHeader(e, "Message-Id"), 255) 250 | if mid == "" { 251 | mid = fmt.Sprintf("%s.%s@%s", hash, e.RcptTo[i].User, config.PrimaryHost) 252 | } 253 | // replyTo is the 'Reply-to' header, it may be blank 254 | replyTo := trimToLimit(s.fillAddressFromHeader(e, "Reply-To"), 255) 255 | // sender is the 'Sender' header, it may be blank 256 | sender := trimToLimit(s.fillAddressFromHeader(e, "Sender"), 255) 257 | 258 | recipient := trimToLimit(strings.TrimSpace(e.RcptTo[i].String()), 255) 259 | contentType := "" 260 | if v, ok := e.Header["Content-Type"]; ok { 261 | contentType = trimToLimit(v[0], 255) 262 | } 263 | 264 | // build the values for the query 265 | vals = []interface{}{} // clear the vals 266 | vals = append(vals, 267 | to, 268 | trimToLimit(e.MailFrom.String(), 255), // from 269 | trimToLimit(e.Subject, 255), 270 | body, // body describes how to interpret the data, eg 'redis' means stored in redis, and 'gzip' stored in mysql, using gzip compression 271 | ) 272 | // `mail` column 273 | if body == "redis" { 274 | // data already saved in redis 275 | vals = append(vals, "") 276 | } else if co != nil { 277 | // use a compressor (automatically adds e.DeliveryHeader) 278 | vals = append(vals, co.String()) 279 | 280 | } else { 281 | vals = append(vals, e.String()) 282 | } 283 | 284 | vals = append(vals, 285 | hash, // hash (redis hash if saved in redis) 286 | contentType, 287 | recipient, 288 | s.ip2bint(e.RemoteIP).Bytes(), // ip_addr store as varbinary(16) 289 | trimToLimit(e.MailFrom.String(), 255), // return_path 290 | // is_tls 291 | e.TLS, 292 | // message_id 293 | mid, 294 | // reply_to 295 | replyTo, 296 | sender, 297 | ) 298 | 299 | stmt := s.prepareInsertQuery(1, db) 300 | err := s.doQuery(1, db, stmt, &vals) 301 | if err != nil { 302 | return NewResult(fmt.Sprint("554 Error: could not save email")), StorageError 303 | } 304 | } 305 | 306 | // continue to the next Processor in the decorator chain 307 | return p.Process(e, task) 308 | } else if task == TaskValidateRcpt { 309 | // if you need to validate the e.Rcpt then change to: 310 | if len(e.RcptTo) > 0 { 311 | // since this is called each time a recipient is added 312 | // validate only the _last_ recipient that was appended 313 | last := e.RcptTo[len(e.RcptTo)-1] 314 | if len(last.User) > 255 { 315 | // return with an error 316 | return NewResult(response.Canned.FailRcptCmd), NoSuchUser 317 | } 318 | } 319 | // continue to the next processor 320 | return p.Process(e, task) 321 | } else { 322 | return p.Process(e, task) 323 | } 324 | 325 | }) 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /mail/envelope.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/md5" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "mime" 11 | "net" 12 | "net/textproto" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/flashmob/go-guerrilla/mail/rfc5321" 18 | ) 19 | 20 | // A WordDecoder decodes MIME headers containing RFC 2047 encoded-words. 21 | // Used by the MimeHeaderDecode function. 22 | // It's exposed public so that an alternative decoder can be set, eg Gnu iconv 23 | // by importing the mail/inconv package. 24 | // Another alternative would be to use https://godoc.org/golang.org/x/text/encoding 25 | var Dec mime.WordDecoder 26 | 27 | func init() { 28 | // use the default decoder, without Gnu inconv. Import the mail/inconv package to use iconv. 29 | Dec = mime.WordDecoder{} 30 | } 31 | 32 | const maxHeaderChunk = 1 + (4 << 10) // 4KB 33 | 34 | // Address encodes an email address of the form `` 35 | type Address struct { 36 | // User is local part 37 | User string 38 | // Host is the domain 39 | Host string 40 | // ADL is at-domain list if matched 41 | ADL []string 42 | // PathParams contains any ESTMP parameters that were matched 43 | PathParams [][]string 44 | // NullPath is true if <> was received 45 | NullPath bool 46 | // Quoted indicates if the local-part needs quotes 47 | Quoted bool 48 | // IP stores the IP Address, if the Host is an IP 49 | IP net.IP 50 | // DisplayName is a label before the address (RFC5322) 51 | DisplayName string 52 | // DisplayNameQuoted is true when DisplayName was quoted 53 | DisplayNameQuoted bool 54 | } 55 | 56 | func (a *Address) String() string { 57 | var local string 58 | if a.IsEmpty() { 59 | return "" 60 | } 61 | if a.User == "postmaster" && a.Host == "" { 62 | return "postmaster" 63 | } 64 | if a.Quoted { 65 | var sb bytes.Buffer 66 | sb.WriteByte('"') 67 | for i := 0; i < len(a.User); i++ { 68 | if a.User[i] == '\\' || a.User[i] == '"' { 69 | // escape 70 | sb.WriteByte('\\') 71 | } 72 | sb.WriteByte(a.User[i]) 73 | } 74 | sb.WriteByte('"') 75 | local = sb.String() 76 | } else { 77 | local = a.User 78 | } 79 | if a.Host != "" { 80 | if a.IP != nil { 81 | return fmt.Sprintf("%s@[%s]", local, a.Host) 82 | } 83 | return fmt.Sprintf("%s@%s", local, a.Host) 84 | } 85 | return local 86 | } 87 | 88 | func (a *Address) IsEmpty() bool { 89 | return a.User == "" && a.Host == "" 90 | } 91 | 92 | func (a *Address) IsPostmaster() bool { 93 | if a.User == "postmaster" { 94 | return true 95 | } 96 | return false 97 | } 98 | 99 | // NewAddress takes a string of an RFC 5322 address of the 100 | // form "Gogh Fir " or "foo@example.com". 101 | func NewAddress(str string) (*Address, error) { 102 | var ap rfc5321.RFC5322 103 | l, err := ap.Address([]byte(str)) 104 | if err != nil { 105 | return nil, err 106 | } 107 | if len(l.List) == 0 { 108 | return nil, errors.New("no email address matched") 109 | } 110 | a := new(Address) 111 | addr := &l.List[0] 112 | a.User = addr.LocalPart 113 | a.Quoted = addr.LocalPartQuoted 114 | a.Host = addr.Domain 115 | a.IP = addr.IP 116 | a.DisplayName = addr.DisplayName 117 | a.DisplayNameQuoted = addr.DisplayNameQuoted 118 | a.NullPath = addr.NullPath 119 | return a, nil 120 | } 121 | 122 | // Envelope of Email represents a single SMTP message. 123 | type Envelope struct { 124 | // Remote IP address 125 | RemoteIP string 126 | // Message sent in EHLO command 127 | Helo string 128 | // Sender 129 | MailFrom Address 130 | // Recipients 131 | RcptTo []Address 132 | // Data stores the header and message body 133 | Data bytes.Buffer 134 | // Subject stores the subject of the email, extracted and decoded after calling ParseHeaders() 135 | Subject string 136 | // TLS is true if the email was received using a TLS connection 137 | TLS bool 138 | // Header stores the results from ParseHeaders() 139 | Header textproto.MIMEHeader 140 | // Values hold the values generated when processing the envelope by the backend 141 | Values map[string]interface{} 142 | // Hashes of each email on the rcpt 143 | Hashes []string 144 | // additional delivery header that may be added 145 | DeliveryHeader string 146 | // Email(s) will be queued with this id 147 | QueuedId string 148 | // ESMTP: true if EHLO was used 149 | ESMTP bool 150 | // When locked, it means that the envelope is being processed by the backend 151 | sync.Mutex 152 | } 153 | 154 | func NewEnvelope(remoteAddr string, clientID uint64) *Envelope { 155 | return &Envelope{ 156 | RemoteIP: remoteAddr, 157 | Values: make(map[string]interface{}), 158 | QueuedId: queuedID(clientID), 159 | } 160 | } 161 | 162 | func queuedID(clientID uint64) string { 163 | return fmt.Sprintf("%x", md5.Sum([]byte(string(time.Now().Unix())+string(clientID)))) 164 | } 165 | 166 | // ParseHeaders parses the headers into Header field of the Envelope struct. 167 | // Data buffer must be full before calling. 168 | // It assumes that at most 30kb of email data can be a header 169 | // Decoding of encoding to UTF is only done on the Subject, where the result is assigned to the Subject field 170 | func (e *Envelope) ParseHeaders() error { 171 | var err error 172 | if e.Header != nil { 173 | return errors.New("headers already parsed") 174 | } 175 | buf := e.Data.Bytes() 176 | // find where the header ends, assuming that over 30 kb would be max 177 | if len(buf) > maxHeaderChunk { 178 | buf = buf[:maxHeaderChunk] 179 | } 180 | 181 | headerEnd := bytes.Index(buf, []byte{'\n', '\n'}) // the first two new-lines chars are the End Of Header 182 | if headerEnd > -1 { 183 | header := buf[0 : headerEnd+2] 184 | headerReader := textproto.NewReader(bufio.NewReader(bytes.NewBuffer(header))) 185 | e.Header, err = headerReader.ReadMIMEHeader() 186 | if err == nil || err == io.EOF { 187 | // decode the subject 188 | if subject, ok := e.Header["Subject"]; ok { 189 | e.Subject = MimeHeaderDecode(subject[0]) 190 | } 191 | } 192 | } else { 193 | err = errors.New("header not found") 194 | } 195 | return err 196 | } 197 | 198 | // Len returns the number of bytes that would be in the reader returned by NewReader() 199 | func (e *Envelope) Len() int { 200 | return len(e.DeliveryHeader) + e.Data.Len() 201 | } 202 | 203 | // NewReader returns a new reader for reading the email contents, including the delivery headers 204 | func (e *Envelope) NewReader() io.Reader { 205 | return io.MultiReader( 206 | strings.NewReader(e.DeliveryHeader), 207 | bytes.NewReader(e.Data.Bytes()), 208 | ) 209 | } 210 | 211 | // String converts the email to string. 212 | // Typically, you would want to use the compressor guerrilla.Processor for more efficiency, or use NewReader 213 | func (e *Envelope) String() string { 214 | return e.DeliveryHeader + e.Data.String() 215 | } 216 | 217 | // ResetTransaction is called when the transaction is reset (keeping the connection open) 218 | func (e *Envelope) ResetTransaction() { 219 | 220 | // ensure not processing by the backend, will only get lock if finished, otherwise block 221 | e.Lock() 222 | // got the lock, it means processing finished 223 | e.Unlock() 224 | 225 | e.MailFrom = Address{} 226 | e.RcptTo = []Address{} 227 | // reset the data buffer, keep it allocated 228 | e.Data.Reset() 229 | 230 | // todo: these are probably good candidates for buffers / use sync.Pool (after profiling) 231 | e.Subject = "" 232 | e.Header = nil 233 | e.Hashes = make([]string, 0) 234 | e.DeliveryHeader = "" 235 | e.Values = make(map[string]interface{}) 236 | } 237 | 238 | // Reseed is called when used with a new connection, once it's accepted 239 | func (e *Envelope) Reseed(remoteIP string, clientID uint64) { 240 | e.RemoteIP = remoteIP 241 | e.QueuedId = queuedID(clientID) 242 | e.Helo = "" 243 | e.TLS = false 244 | e.ESMTP = false 245 | } 246 | 247 | // PushRcpt adds a recipient email address to the envelope 248 | func (e *Envelope) PushRcpt(addr Address) { 249 | e.RcptTo = append(e.RcptTo, addr) 250 | } 251 | 252 | // PopRcpt removes the last email address that was pushed to the envelope 253 | func (e *Envelope) PopRcpt() Address { 254 | ret := e.RcptTo[len(e.RcptTo)-1] 255 | e.RcptTo = e.RcptTo[:len(e.RcptTo)-1] 256 | return ret 257 | } 258 | 259 | const ( 260 | statePlainText = iota 261 | stateStartEncodedWord 262 | stateEncodedWord 263 | stateEncoding 264 | stateCharset 265 | statePayload 266 | statePayloadEnd 267 | ) 268 | 269 | // MimeHeaderDecode converts 7 bit encoded mime header strings to UTF-8 270 | func MimeHeaderDecode(str string) string { 271 | // optimized to only create an output buffer if there's need to 272 | // the `out` buffer is only made if an encoded word was decoded without error 273 | // `out` is made with the capacity of len(str) 274 | // a simple state machine is used to detect the start & end of encoded word and plain-text 275 | state := statePlainText 276 | var ( 277 | out []byte 278 | wordStart int // start of an encoded word 279 | wordLen int // end of an encoded 280 | ptextStart = -1 // start of plan-text 281 | ptextLen int // end of plain-text 282 | ) 283 | for i := 0; i < len(str); i++ { 284 | switch state { 285 | case statePlainText: 286 | if ptextStart == -1 { 287 | ptextStart = i 288 | } 289 | if str[i] == '=' { 290 | state = stateStartEncodedWord 291 | wordStart = i 292 | wordLen = 1 293 | } else { 294 | ptextLen++ 295 | } 296 | case stateStartEncodedWord: 297 | if str[i] == '?' { 298 | wordLen++ 299 | state = stateCharset 300 | } else { 301 | wordLen = 0 302 | state = statePlainText 303 | ptextLen++ 304 | } 305 | case stateCharset: 306 | if str[i] == '?' { 307 | wordLen++ 308 | state = stateEncoding 309 | } else if str[i] >= 'a' && str[i] <= 'z' || 310 | str[i] >= 'A' && str[i] <= 'Z' || 311 | str[i] >= '0' && str[i] <= '9' || str[i] == '-' { 312 | wordLen++ 313 | } else { 314 | // error 315 | state = statePlainText 316 | ptextLen += wordLen 317 | wordLen = 0 318 | } 319 | case stateEncoding: 320 | if str[i] == '?' { 321 | wordLen++ 322 | state = statePayload 323 | } else if str[i] == 'Q' || str[i] == 'q' || str[i] == 'b' || str[i] == 'B' { 324 | wordLen++ 325 | } else { 326 | // abort 327 | state = statePlainText 328 | ptextLen += wordLen 329 | wordLen = 0 330 | } 331 | 332 | case statePayload: 333 | if str[i] == '?' { 334 | wordLen++ 335 | state = statePayloadEnd 336 | } else { 337 | wordLen++ 338 | } 339 | 340 | case statePayloadEnd: 341 | if str[i] == '=' { 342 | wordLen++ 343 | var err error 344 | out, err = decodeWordAppend(ptextLen, out, str, ptextStart, wordStart, wordLen) 345 | if err != nil && out == nil { 346 | // special case: there was an error with decoding and `out` wasn't created 347 | // we can assume the encoded word as plaintext 348 | ptextLen += wordLen //+ 1 // add 1 for the space/tab 349 | wordLen = 0 350 | wordStart = 0 351 | state = statePlainText 352 | continue 353 | } 354 | if skip := hasEncodedWordAhead(str, i+1); skip != -1 { 355 | i = skip 356 | } else { 357 | out = makeAppend(out, len(str), []byte{}) 358 | } 359 | ptextStart = -1 360 | ptextLen = 0 361 | wordLen = 0 362 | wordStart = 0 363 | state = statePlainText 364 | } else { 365 | // abort 366 | state = statePlainText 367 | ptextLen += wordLen 368 | wordLen = 0 369 | } 370 | 371 | } 372 | } 373 | 374 | if out != nil && ptextLen > 0 { 375 | out = makeAppend(out, len(str), []byte(str[ptextStart:ptextStart+ptextLen])) 376 | ptextLen = 0 377 | } 378 | 379 | if out == nil { 380 | // best case: there was nothing to encode 381 | return str 382 | } 383 | return string(out) 384 | } 385 | 386 | func decodeWordAppend(ptextLen int, out []byte, str string, ptextStart int, wordStart int, wordLen int) ([]byte, error) { 387 | if ptextLen > 0 { 388 | out = makeAppend(out, len(str), []byte(str[ptextStart:ptextStart+ptextLen])) 389 | } 390 | d, err := Dec.Decode(str[wordStart : wordLen+wordStart]) 391 | if err == nil { 392 | out = makeAppend(out, len(str), []byte(d)) 393 | } else if out != nil { 394 | out = makeAppend(out, len(str), []byte(str[wordStart:wordLen+wordStart])) 395 | } 396 | return out, err 397 | } 398 | 399 | func makeAppend(out []byte, size int, in []byte) []byte { 400 | if out == nil { 401 | out = make([]byte, 0, size) 402 | } 403 | out = append(out, in...) 404 | return out 405 | } 406 | 407 | func hasEncodedWordAhead(str string, i int) int { 408 | for ; i+2 < len(str); i++ { 409 | if str[i] != ' ' && str[i] != '\t' { 410 | return -1 411 | } 412 | if str[i+1] == '=' && str[i+2] == '?' { 413 | return i 414 | } 415 | } 416 | return -1 417 | } 418 | 419 | // Envelopes have their own pool 420 | 421 | type Pool struct { 422 | // envelopes that are ready to be borrowed 423 | pool chan *Envelope 424 | // semaphore to control number of maximum borrowed envelopes 425 | sem chan bool 426 | } 427 | 428 | func NewPool(poolSize int) *Pool { 429 | return &Pool{ 430 | pool: make(chan *Envelope, poolSize), 431 | sem: make(chan bool, poolSize), 432 | } 433 | } 434 | 435 | func (p *Pool) Borrow(remoteAddr string, clientID uint64) *Envelope { 436 | var e *Envelope 437 | p.sem <- true // block the envelope until more room 438 | select { 439 | case e = <-p.pool: 440 | e.Reseed(remoteAddr, clientID) 441 | default: 442 | e = NewEnvelope(remoteAddr, clientID) 443 | } 444 | return e 445 | } 446 | 447 | // Return returns an envelope back to the envelope pool 448 | // Make sure that envelope finished processing before calling this 449 | func (p *Pool) Return(e *Envelope) { 450 | select { 451 | case p.pool <- e: 452 | //placed envelope back in pool 453 | default: 454 | // pool is full, discard it 455 | } 456 | // take a value off the semaphore to make room for more envelopes 457 | <-p.sem 458 | } 459 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | > [!IMPORTANT] 3 | > Hi, my name is Philipp and I am one of the contributors to this project. 4 | > Sadly it seems as if the original owner has abandonded go-guerilla. 5 | > As I think there are still some nice things to do with it, and there might 6 | > be some issues here and there, I have decided to revive this project. 7 | > 8 | > I've already mirrored the repository to https://github.com/phires/go-guerrilla 9 | > as I have not complete full access to this repo and don't know if it will 10 | > disappear at some point. 11 | > I will also try to migrate all further relevant informations (e.g. Wiki 12 | > and Issues) over to the new repo. 13 | > 14 | > If the original owner decides to come back I'll glady hand over full control 15 | > of the projekt back to him. This should by no means be misinterpreted as a 16 | > "hostile takeover" or anything. I just want to get this mighty fine piece 17 | > of software back to speed and give it some further development. 18 | > 19 | > -- 2023-08-31 Philipp 20 | 21 | 22 | Latest: v1.6.1, tagged on Dec 28, 2019 (Pull requests from #129 to #203) 23 | 24 | Go-Guerrilla SMTP Daemon 25 | ==================== 26 | 27 | A lightweight SMTP server written in Go, made for receiving large volumes of mail. 28 | To be used as a package in your Go project, or as a stand-alone daemon by running the "guerrillad" binary. 29 | 30 | Supports MySQL and Redis out-of-the-box, with many other vendor provided _processors_, 31 | such as [MailDir](https://github.com/flashmob/maildir-processor) and even [FastCGI](https://github.com/flashmob/fastcgi-processor)! 32 | See below for a list of available processors. 33 | 34 | ![Go Guerrilla](/GoGuerrilla.png) 35 | 36 | ### What is Go-Guerrilla? 37 | 38 | It's an SMTP server written in Go, for the purpose of receiving large volumes of email. 39 | It started as a project for GuerrillaMail.com which processes millions of emails every day, 40 | and needed a daemon with less bloat & written in a more memory-safe language that can 41 | take advantage of modern multi-core architectures. 42 | 43 | The purpose of this daemon is to grab the email, save it, 44 | and disconnect as quickly as possible, essentially performing the services of a 45 | Mail Transfer Agent (MTA) without the sending functionality. 46 | 47 | The software also includes a modular backend implementation, which can extend the email 48 | processing functionality to whatever needs you may require. We refer to these modules as 49 | "_Processors_". Processors can be chained via the config to perform different tasks on 50 | received email, or to validate recipients. 51 | 52 | See the list of available _Processors_ below. 53 | 54 | For more details about the backend system, see the: 55 | [Backends, configuring and extending](https://github.com/flashmob/go-guerrilla/wiki/Backends,-configuring-and-extending) page. 56 | 57 | ### License 58 | 59 | The software is using MIT License (MIT) - contributors welcome. 60 | 61 | ### Features 62 | 63 | #### Main Features 64 | 65 | - Multi-server. Can spawn multiple servers, all sharing the same backend 66 | for saving email. 67 | - Config hot-reloading. Add/Remove/Enable/Disable servers without restarting. 68 | Reload TLS configuration, change most other settings on the fly. 69 | - Graceful shutdown: Minimise loss of email if you need to shutdown/restart. 70 | - Be a gentleman to the garbage collector: resources are pooled & recycled where possible. 71 | - Modular [Backend system](https://github.com/flashmob/go-guerrilla/wiki/Backends,-configuring-and-extending) 72 | - Modern TLS support (STARTTLS or SMTPS). 73 | - Can be [used as a package](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package) in your Go project. 74 | Get started in just a few lines of code! 75 | - [Fuzz tested](https://github.com/flashmob/go-guerrilla/wiki/Fuzz-testing). 76 | [Auto-tested](https://travis-ci.org/flashmob/go-guerrilla). Battle Tested. 77 | 78 | #### Backend Features 79 | 80 | - Arranged as workers running in parallel, using a producer/consumer type structure, 81 | taking advantage of Go's channels and go-routines. 82 | - Modular [backend system](https://github.com/flashmob/go-guerrilla/wiki/Backends,-configuring-and-extending) 83 | structured using a [decorator-like pattern](https://en.wikipedia.org/wiki/Decorator_pattern) which allows the chaining of components (a.k.a. _Processors_) via the config. 84 | - Different ways for processing / delivering email: Supports MySQL and Redis out-of-the box, many other 85 | vendor provided processors available. 86 | 87 | ### Roadmap / Contributing & Bounties 88 | 89 | Pull requests / issue reporting & discussion / code reviews always 90 | welcome. To encourage more pull requests, we are now offering bounties. 91 | 92 | Take a look at our [Bounties and Roadmap](https://github.com/flashmob/go-guerrilla/wiki/Roadmap-and-Bounties) page! 93 | 94 | 95 | Getting started 96 | =========================== 97 | 98 | (Assuming that you have GNU make and latest Go on your system) 99 | 100 | #### Dependencies 101 | 102 | Go-Guerrilla uses [Dep](https://golang.github.io/dep/) to manage 103 | dependencies. If you have dep installed, just run `dep ensure` as usual. 104 | 105 | You can also run `$ go get ./..` if you don't want to use dep, and then run `$ make test` 106 | to ensure all is good. 107 | 108 | To build the binary run: 109 | 110 | ``` 111 | $ make guerrillad 112 | ``` 113 | 114 | This will create a executable file named `guerrillad` that's ready to run. 115 | See the [build notes](https://github.com/flashmob/go-guerrilla/wiki/Build-Notes) for more details. 116 | 117 | Next, copy the `goguerrilla.conf.sample` file to `goguerrilla.conf.json`. 118 | You may need to customize the `pid_file` setting to somewhere local, 119 | and also set `tls_always_on` to false if you don't have a valid certificate setup yet. 120 | 121 | Next, run your server like this: 122 | 123 | `$ ./guerrillad serve` 124 | 125 | The configuration options are detailed on the [configuration page](https://github.com/flashmob/go-guerrilla/wiki/Configuration). 126 | The main takeaway here is: 127 | 128 | The default configuration uses 3 _processors_, they are set using the `save_process` 129 | config option. Notice that it contains the following value: 130 | `"HeadersParser|Header|Debugger"` - this means, once an email is received, it will 131 | first go through the `HeadersParser` processor where headers will be parsed. 132 | Next, it will go through the `Header` processor, where delivery headers will be added. 133 | Finally, it will finish at the `Debugger` which will log some debug messages. 134 | 135 | Where to go next? 136 | 137 | - Try setting up an [example configuration](https://github.com/flashmob/go-guerrilla/wiki/Configuration-example:-save-to-Redis-&-MySQL) 138 | which saves email bodies to Redis and metadata to MySQL. 139 | - Try importing some of the 'vendored' processors into your project. See [MailDiranasaurus](https://github.com/flashmob/maildiranasaurus) 140 | as an example project which imports the [MailDir](https://github.com/flashmob/maildir-processor) and [FastCGI](https://github.com/flashmob/fastcgi-processor) processors. 141 | - Try hacking the source and [create your own processor](https://github.com/flashmob/go-guerrilla/wiki/Backends,-configuring-and-extending). 142 | - Once your daemon is running, you might want to stup [log rotation](https://github.com/flashmob/go-guerrilla/wiki/Automatic-log-file-management-with-logrotate). 143 | 144 | 145 | 146 | Use as a package 147 | ============================ 148 | Go-Guerrilla can be imported and used as a package in your Go project. 149 | 150 | ### Quickstart 151 | 152 | 153 | #### 1. Import the guerrilla package 154 | ```go 155 | import ( 156 | "github.com/flashmob/go-guerrilla" 157 | ) 158 | 159 | 160 | ``` 161 | 162 | You should use the `dep ensure` command to get all dependencies, as Go-Guerrilla uses 163 | [dep](https://golang.github.io/dep/) for dependency management. 164 | 165 | Otherise, ``$ go get ./...`` should work if you're in a hurry. 166 | 167 | #### 2. Start a server 168 | 169 | This will start a server with the default settings, listening on `127.0.0.1:2525` 170 | 171 | 172 | ```go 173 | 174 | d := guerrilla.Daemon{} 175 | err := d.Start() 176 | 177 | if err == nil { 178 | fmt.Println("Server Started!") 179 | } 180 | ``` 181 | 182 | `d.Start()` *does not block* after the server has been started, so make sure that you keep your program busy. 183 | 184 | The defaults are: 185 | * Server listening to 127.0.0.1:2525 186 | * use your hostname to determine your which hosts to accept email for 187 | * 100 maximum clients 188 | * 10MB max message size 189 | * log to Stderror, 190 | * log level set to "`debug`" 191 | * timeout to 30 sec 192 | * Backend configured with the following processors: `HeadersParser|Header|Debugger` where it will log the received emails. 193 | 194 | Next, you may want to [change the interface](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#starting-a-server---custom-listening-interface) (`127.0.0.1:2525`) to the one of your own choice. 195 | 196 | #### API Documentation topics 197 | 198 | Please continue to the [API documentation](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package) for the following topics: 199 | 200 | 201 | - [Suppressing log output](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#starting-a-server---suppressing-log-output) 202 | - [Custom listening interface](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#starting-a-server---custom-listening-interface) 203 | - [What else can be configured](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#what-else-can-be-configured) 204 | - [Backends](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#backends) 205 | - [About the backend system](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#about-the-backend-system) 206 | - [Backend Configuration](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#backend-configuration) 207 | - [Registering a Processor](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#registering-a-processor) 208 | - [Loading config from JSON](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#loading-config-from-json) 209 | - [Config hot-reloading](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#config-hot-reloading) 210 | - [Logging](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#logging-stuff) 211 | - [Log re-opening](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#log-re-opening) 212 | - [Graceful shutdown](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#graceful-shutdown) 213 | - [Pub/Sub](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#pubsub) 214 | - [More Examples](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#more-examples) 215 | 216 | Use as a Daemon 217 | ========================================================== 218 | 219 | ### Manual for using from the command line 220 | 221 | - [guerrillad command](https://github.com/flashmob/go-guerrilla/wiki/Running-from-command-line#guerrillad-command) 222 | - [Starting](https://github.com/flashmob/go-guerrilla/wiki/Running-from-command-line#starting) 223 | - [Re-loading configuration](https://github.com/flashmob/go-guerrilla/wiki/Running-from-command-line#re-loading-the-config) 224 | - [Re-open logs](https://github.com/flashmob/go-guerrilla/wiki/Running-from-command-line#re-open-log-file) 225 | - [Examples](https://github.com/flashmob/go-guerrilla/wiki/Running-from-command-line#examples) 226 | 227 | ### Other topics 228 | 229 | - [Using Nginx as a proxy](https://github.com/flashmob/go-guerrilla/wiki/Using-Nginx-as-a-proxy) 230 | - [Testing STARTTLS](https://github.com/flashmob/go-guerrilla/wiki/Running-from-command-line#testing-starttls) 231 | - [Benchmarking](https://github.com/flashmob/go-guerrilla/wiki/Profiling#benchmarking) 232 | 233 | 234 | Email Processing Backend 235 | ===================== 236 | 237 | The main job of a Go-Guerrilla backend is to validate recipients and deliver emails. The term 238 | "delivery" is often synonymous with saving email to secondary storage. 239 | 240 | The default backend implementation manages multiple workers. These workers are composed of 241 | smaller components called "Processors" which are chained using the config to perform a series of steps. 242 | Each processor specifies a distinct feature of behaviour. For example, a processor may save 243 | the emails to a particular storage system such as MySQL, or it may add additional headers before 244 | passing the email to the next _processor_. 245 | 246 | To extend or add a new feature, one would write a new Processor, then add it to the config. 247 | There are a few default _processors_ to get you started. 248 | 249 | 250 | ### Included Processors 251 | 252 | | Processor | Description | 253 | |-----------|-------------| 254 | |Compressor|Sets a zlib compressor that other processors can use later| 255 | |Debugger|Logs the email envelope to help with testing| 256 | |Hasher|Processes each envelope to produce unique hashes to be used for ids later| 257 | |Header|Add a delivery header to the envelope| 258 | |HeadersParser|Parses MIME headers and also populates the Subject field of the envelope| 259 | |MySQL|Saves the emails to MySQL.| 260 | |Redis|Saves the email data to Redis.| 261 | |GuerrillaDbRedis|A 'monolithic' processor used at Guerrilla Mail; included for example 262 | 263 | ### Available Processors 264 | 265 | The following processors can be imported to your project, then use the 266 | [Daemon.AddProcessor](https://github.com/flashmob/go-guerrilla/wiki/Using-as-a-package#registering-a-processor) function to register, then add to your config. 267 | 268 | | Processor | Description | 269 | |-----------|-------------| 270 | |[MailDir](https://github.com/flashmob/maildir-processor)|Save emails to a maildir. [MailDiranasaurus](https://github.com/flashmob/maildiranasaurus) is an example project| 271 | |[FastCGI](https://github.com/flashmob/fastcgi-processor)|Deliver email directly to PHP-FPM or a similar FastCGI backend.| 272 | |[WildcardProcessor](https://github.com/DevelHell/wildcard-processor)|Use wildcards for recipients host validation.| 273 | 274 | Have a processor that you would like to share? Submit a PR to add it to the list! 275 | 276 | Releases 277 | ======== 278 | 279 | Current release: 1.5.1 - 4th Nov 2016 280 | 281 | Next Planned release: 2.0.0 - TBA 282 | 283 | See our [change log](https://github.com/flashmob/go-guerrilla/wiki/Change-Log) for change and release history 284 | 285 | 286 | Using Nginx as a proxy 287 | ====================== 288 | 289 | For such purposes as load balancing, terminating TLS early, 290 | or supporting SSL versions not supported by Go (highly not recommended if you 291 | want to use older TLS/SSL versions), 292 | it is possible to [use NGINX as a proxy](https://github.com/flashmob/go-guerrilla/wiki/Using-Nginx-as-a-proxy). 293 | 294 | 295 | 296 | Credits 297 | ======= 298 | 299 | Project Lead: 300 | ------------- 301 | Flashmob, GuerrillaMail.com, Contact: flashmob@gmail.com 302 | 303 | Major Contributors: 304 | ------------------- 305 | 306 | * Reza Mohammadi https://github.com/remohammadi 307 | * Jordan Schalm https://github.com/jordanschalm 308 | * Philipp Resch https://github.com/dapaxx 309 | 310 | Thanks to: 311 | ---------- 312 | * https://github.com/dvcrn 313 | * https://github.com/athoune 314 | * https://github.com/Xeoncross 315 | 316 | ... and anyone else who opened an issue / sent a PR / gave suggestions! 317 | -------------------------------------------------------------------------------- /backends/p_guerrilla_db_redis.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "database/sql" 7 | "fmt" 8 | "io" 9 | "math/rand" 10 | "runtime/debug" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/flashmob/go-guerrilla/mail" 16 | ) 17 | 18 | // ---------------------------------------------------------------------------------- 19 | // Processor Name: GuerrillaRedisDB 20 | // ---------------------------------------------------------------------------------- 21 | // Description : Saves the body to redis, meta data to SQL. Example only. 22 | // : Limitation: it doesn't save multiple recipients or validate them 23 | // ---------------------------------------------------------------------------------- 24 | // Config Options: ... 25 | // --------------:------------------------------------------------------------------- 26 | // Input : envelope 27 | // ---------------------------------------------------------------------------------- 28 | // Output : 29 | // ---------------------------------------------------------------------------------- 30 | func init() { 31 | processors["guerrillaredisdb"] = func() Decorator { 32 | return GuerrillaDbRedis() 33 | } 34 | } 35 | 36 | var queryBatcherId = 0 37 | 38 | // how many rows to batch at a time 39 | const GuerrillaDBAndRedisBatchMax = 50 40 | 41 | // tick on every... 42 | const GuerrillaDBAndRedisBatchTimeout = time.Second * 3 43 | 44 | type GuerrillaDBAndRedisBackend struct { 45 | config *guerrillaDBAndRedisConfig 46 | batcherWg sync.WaitGroup 47 | // cache prepared queries 48 | cache stmtCache 49 | 50 | batcherStoppers []chan bool 51 | } 52 | 53 | // statement cache. It's an array, not slice 54 | type stmtCache [GuerrillaDBAndRedisBatchMax]*sql.Stmt 55 | 56 | type guerrillaDBAndRedisConfig struct { 57 | NumberOfWorkers int `json:"save_workers_size"` 58 | Table string `json:"mail_table"` 59 | Driver string `json:"sql_driver"` 60 | DSN string `json:"sql_dsn"` 61 | RedisExpireSeconds int `json:"redis_expire_seconds"` 62 | RedisInterface string `json:"redis_interface"` 63 | PrimaryHost string `json:"primary_mail_host"` 64 | BatchTimeout int `json:"redis_sql_batch_timeout,omitempty"` 65 | } 66 | 67 | // Load the backend config for the backend. It has already been unmarshalled 68 | // from the main config file 'backend' config "backend_config" 69 | // Now we need to convert each type and copy into the guerrillaDBAndRedisConfig struct 70 | func (g *GuerrillaDBAndRedisBackend) loadConfig(backendConfig BackendConfig) (err error) { 71 | configType := BaseConfig(&guerrillaDBAndRedisConfig{}) 72 | bcfg, err := Svc.ExtractConfig(backendConfig, configType) 73 | if err != nil { 74 | return err 75 | } 76 | m := bcfg.(*guerrillaDBAndRedisConfig) 77 | g.config = m 78 | return nil 79 | } 80 | 81 | func (g *GuerrillaDBAndRedisBackend) getNumberOfWorkers() int { 82 | return g.config.NumberOfWorkers 83 | } 84 | 85 | type redisClient struct { 86 | isConnected bool 87 | conn RedisConn 88 | time int 89 | } 90 | 91 | // compressedData struct will be compressed using zlib when printed via fmt 92 | type compressedData struct { 93 | extraHeaders []byte 94 | data *bytes.Buffer 95 | pool *sync.Pool 96 | } 97 | 98 | // newCompressedData returns a new CompressedData 99 | func newCompressedData() *compressedData { 100 | var p = sync.Pool{ 101 | New: func() interface{} { 102 | var b bytes.Buffer 103 | return &b 104 | }, 105 | } 106 | return &compressedData{ 107 | pool: &p, 108 | } 109 | } 110 | 111 | // Set the extraheaders and buffer of data to compress 112 | func (c *compressedData) set(b []byte, d *bytes.Buffer) { 113 | c.extraHeaders = b 114 | c.data = d 115 | } 116 | 117 | // implement Stringer interface 118 | func (c *compressedData) String() string { 119 | if c.data == nil { 120 | return "" 121 | } 122 | //borrow a buffer form the pool 123 | b := c.pool.Get().(*bytes.Buffer) 124 | // put back in the pool 125 | defer func() { 126 | b.Reset() 127 | c.pool.Put(b) 128 | }() 129 | 130 | var r *bytes.Reader 131 | w, _ := zlib.NewWriterLevel(b, zlib.BestSpeed) 132 | r = bytes.NewReader(c.extraHeaders) 133 | _, _ = io.Copy(w, r) 134 | _, _ = io.Copy(w, c.data) 135 | _ = w.Close() 136 | return b.String() 137 | } 138 | 139 | // clear it, without clearing the pool 140 | func (c *compressedData) clear() { 141 | c.extraHeaders = []byte{} 142 | c.data = nil 143 | } 144 | 145 | // prepares the sql query with the number of rows that can be batched with it 146 | func (g *GuerrillaDBAndRedisBackend) prepareInsertQuery(rows int, db *sql.DB) *sql.Stmt { 147 | if rows == 0 { 148 | panic("rows argument cannot be 0") 149 | } 150 | if g.cache[rows-1] != nil { 151 | return g.cache[rows-1] 152 | } 153 | sqlstr := "INSERT INTO " + g.config.Table + "" + 154 | "(" + 155 | "`date`, " + 156 | "`to`, " + 157 | "`from`, " + 158 | "`subject`, " + 159 | "`body`, " + 160 | "`charset`, " + 161 | "`mail`, " + 162 | "`spam_score`, " + 163 | "`hash`, " + 164 | "`content_type`, " + 165 | "`recipient`, " + 166 | "`has_attach`, " + 167 | "`ip_addr`, " + 168 | "`return_path`, " + 169 | "`is_tls`" + 170 | ")" + 171 | " values " 172 | values := "(NOW(), ?, ?, ?, ? , 'UTF-8' , ?, 0, ?, '', ?, 0, ?, ?, ?)" 173 | // add more rows 174 | comma := "" 175 | for i := 0; i < rows; i++ { 176 | sqlstr += comma + values 177 | if comma == "" { 178 | comma = "," 179 | } 180 | } 181 | stmt, sqlErr := db.Prepare(sqlstr) 182 | if sqlErr != nil { 183 | Log().WithError(sqlErr).Fatalf("failed while db.Prepare(INSERT...)") 184 | } 185 | // cache it 186 | g.cache[rows-1] = stmt 187 | return stmt 188 | } 189 | 190 | func (g *GuerrillaDBAndRedisBackend) doQuery(c int, db *sql.DB, insertStmt *sql.Stmt, vals *[]interface{}) error { 191 | var execErr error 192 | defer func() { 193 | if r := recover(); r != nil { 194 | //logln(1, fmt.Sprintf("Recovered in %v", r)) 195 | Log().Error("Recovered form panic:", r, string(debug.Stack())) 196 | sum := 0 197 | for _, v := range *vals { 198 | if str, ok := v.(string); ok { 199 | sum = sum + len(str) 200 | } 201 | } 202 | Log().Errorf("panic while inserting query [%s] size:%d, err %v", r, sum, execErr) 203 | panic("query failed") 204 | } 205 | }() 206 | // prepare the query used to insert when rows reaches batchMax 207 | insertStmt = g.prepareInsertQuery(c, db) 208 | _, execErr = insertStmt.Exec(*vals...) 209 | //if rand.Intn(2) == 1 { 210 | // return errors.New("uggabooka") 211 | //} 212 | if execErr != nil { 213 | Log().WithError(execErr).Error("There was a problem the insert") 214 | } 215 | return execErr 216 | } 217 | 218 | // Batches the rows from the feeder chan in to a single INSERT statement. 219 | // Execute the batches query when: 220 | // - number of batched rows reaches a threshold, i.e. count n = threshold 221 | // - or, no new rows within a certain time, i.e. times out 222 | // The goroutine can either exit if there's a panic or feeder channel closes 223 | // it returns feederOk which signals if the feeder chanel was ok (still open) while returning 224 | // if it feederOk is false, then it means the feeder chanel is closed 225 | func (g *GuerrillaDBAndRedisBackend) insertQueryBatcher( 226 | feeder feedChan, 227 | db *sql.DB, 228 | batcherId int, 229 | stop chan bool) (feederOk bool) { 230 | 231 | // controls shutdown 232 | defer g.batcherWg.Done() 233 | g.batcherWg.Add(1) 234 | // vals is where values are batched to 235 | var vals []interface{} 236 | // how many rows were batched 237 | count := 0 238 | // The timer will tick x seconds. 239 | // Interrupting the select clause when there's no data on the feeder channel 240 | timeo := GuerrillaDBAndRedisBatchTimeout 241 | if g.config.BatchTimeout > 0 { 242 | timeo = time.Duration(g.config.BatchTimeout) 243 | } 244 | t := time.NewTimer(timeo) 245 | // prepare the query used to insert when rows reaches batchMax 246 | insertStmt := g.prepareInsertQuery(GuerrillaDBAndRedisBatchMax, db) 247 | // inserts executes a batched insert query, clears the vals and resets the count 248 | inserter := func(c int) { 249 | if c > 0 { 250 | err := g.doQuery(c, db, insertStmt, &vals) 251 | if err != nil { 252 | // maybe connection prob? 253 | // retry the sql query 254 | attempts := 3 255 | for i := 0; i < attempts; i++ { 256 | Log().Infof("retrying query query rows[%c] ", c) 257 | time.Sleep(time.Second) 258 | err = g.doQuery(c, db, insertStmt, &vals) 259 | if err == nil { 260 | continue 261 | } 262 | } 263 | } 264 | } 265 | vals = nil 266 | count = 0 267 | } 268 | rand.Seed(time.Now().UnixNano()) 269 | defer func() { 270 | if r := recover(); r != nil { 271 | Log().Error("insertQueryBatcher caught a panic", r, string(debug.Stack())) 272 | } 273 | }() 274 | // Keep getting values from feeder and add to batch. 275 | // if feeder times out, execute the batched query 276 | // otherwise, execute the batched query once it reaches the GuerrillaDBAndRedisBatchMax threshold 277 | feederOk = true 278 | for { 279 | select { 280 | // it may panic when reading on a closed feeder channel. feederOK detects if it was closed 281 | case <-stop: 282 | Log().Infof("MySQL query batcher stopped (#%d)", batcherId) 283 | // Insert any remaining rows 284 | inserter(count) 285 | feederOk = false 286 | close(feeder) 287 | return 288 | case row := <-feeder: 289 | 290 | vals = append(vals, row...) 291 | count++ 292 | Log().Debug("new feeder row:", row, " cols:", len(row), " count:", count, " worker", batcherId) 293 | if count >= GuerrillaDBAndRedisBatchMax { 294 | inserter(GuerrillaDBAndRedisBatchMax) 295 | } 296 | // stop timer from firing (reset the interrupt) 297 | if !t.Stop() { 298 | // darin the timer 299 | <-t.C 300 | } 301 | t.Reset(timeo) 302 | case <-t.C: 303 | // anything to insert? 304 | if n := len(vals); n > 0 { 305 | inserter(count) 306 | } 307 | t.Reset(timeo) 308 | } 309 | } 310 | } 311 | 312 | func trimToLimit(str string, limit int) string { 313 | ret := strings.TrimSpace(str) 314 | if len(str) > limit { 315 | ret = str[:limit] 316 | } 317 | return ret 318 | } 319 | 320 | func (g *GuerrillaDBAndRedisBackend) sqlConnect() (*sql.DB, error) { 321 | if db, err := sql.Open(g.config.Driver, g.config.DSN); err != nil { 322 | Log().Error("cannot open database", err, "]") 323 | return nil, err 324 | } else { 325 | // do we have access? 326 | _, err = db.Query("SELECT mail_id FROM " + g.config.Table + " LIMIT 1") 327 | if err != nil { 328 | Log().Error("cannot select table:", err) 329 | return nil, err 330 | } 331 | return db, nil 332 | } 333 | } 334 | 335 | func (c *redisClient) redisConnection(redisInterface string) (err error) { 336 | if c.isConnected == false { 337 | c.conn, err = RedisDialer("tcp", redisInterface) 338 | if err != nil { 339 | // handle error 340 | return err 341 | } 342 | c.isConnected = true 343 | } 344 | return nil 345 | } 346 | 347 | type feedChan chan []interface{} 348 | 349 | // GuerrillaDbRedis is a specialized processor for Guerrilla mail. It is here as an example. 350 | // It's an example of a 'monolithic' processor. 351 | func GuerrillaDbRedis() Decorator { 352 | 353 | g := GuerrillaDBAndRedisBackend{} 354 | redisClient := &redisClient{} 355 | 356 | var ( 357 | db *sql.DB 358 | to, body string 359 | redisErr error 360 | feeders []feedChan 361 | ) 362 | 363 | g.batcherStoppers = make([]chan bool, 0) 364 | 365 | Svc.AddInitializer(InitializeWith(func(backendConfig BackendConfig) error { 366 | 367 | configType := BaseConfig(&guerrillaDBAndRedisConfig{}) 368 | bcfg, err := Svc.ExtractConfig(backendConfig, configType) 369 | if err != nil { 370 | return err 371 | } 372 | g.config = bcfg.(*guerrillaDBAndRedisConfig) 373 | db, err = g.sqlConnect() 374 | if err != nil { 375 | return err 376 | } 377 | queryBatcherId++ 378 | // start the query SQL batching where we will send data via the feeder channel 379 | stop := make(chan bool) 380 | feeder := make(feedChan, 1) 381 | go func(qbID int, stop chan bool) { 382 | // we loop so that if insertQueryBatcher panics, it can recover and go in again 383 | for { 384 | if feederOK := g.insertQueryBatcher(feeder, db, qbID, stop); !feederOK { 385 | Log().Debugf("insertQueryBatcher exited (#%d)", qbID) 386 | return 387 | } 388 | Log().Debug("resuming insertQueryBatcher") 389 | } 390 | }(queryBatcherId, stop) 391 | g.batcherStoppers = append(g.batcherStoppers, stop) 392 | feeders = append(feeders, feeder) 393 | return nil 394 | })) 395 | 396 | Svc.AddShutdowner(ShutdownWith(func() error { 397 | if err := db.Close(); err != nil { 398 | Log().WithError(err).Error("close mysql failed") 399 | } else { 400 | Log().Infof("closed mysql") 401 | } 402 | if redisClient.conn != nil { 403 | if err := redisClient.conn.Close(); err != nil { 404 | Log().WithError(err).Error("close redis failed") 405 | } else { 406 | Log().Infof("closed redis") 407 | } 408 | } 409 | // send a close signal to all query batchers to exit. 410 | for i := range g.batcherStoppers { 411 | g.batcherStoppers[i] <- true 412 | } 413 | g.batcherWg.Wait() 414 | 415 | return nil 416 | })) 417 | 418 | var vals []interface{} 419 | data := newCompressedData() 420 | 421 | return func(p Processor) Processor { 422 | return ProcessWith(func(e *mail.Envelope, task SelectTask) (Result, error) { 423 | if task == TaskSaveMail { 424 | Log().Debug("Got mail from chan,", e.RemoteIP) 425 | to = trimToLimit(strings.TrimSpace(e.RcptTo[0].User)+"@"+g.config.PrimaryHost, 255) 426 | e.Helo = trimToLimit(e.Helo, 255) 427 | e.RcptTo[0].Host = trimToLimit(e.RcptTo[0].Host, 255) 428 | ts := fmt.Sprintf("%d", time.Now().UnixNano()) 429 | if err := e.ParseHeaders(); err != nil { 430 | Log().WithError(err).Error("failed to parse headers") 431 | } 432 | hash := MD5Hex( 433 | to, 434 | e.MailFrom.String(), 435 | e.Subject, 436 | ts) 437 | e.QueuedId = hash 438 | 439 | // Add extra headers 440 | protocol := "SMTP" 441 | if e.ESMTP { 442 | protocol = "E" + protocol 443 | } 444 | if e.TLS { 445 | protocol = protocol + "S" 446 | } 447 | var addHead string 448 | addHead += "Delivered-To: " + to + "\r\n" 449 | addHead += "Received: from " + e.RemoteIP + " ([" + e.RemoteIP + "])\r\n" 450 | addHead += " by " + e.RcptTo[0].Host + " with " + protocol + " id " + hash + "@" + e.RcptTo[0].Host + ";\r\n" 451 | addHead += " " + time.Now().Format(time.RFC1123Z) + "\r\n" 452 | 453 | // data will be compressed when printed, with addHead added to beginning 454 | 455 | data.set([]byte(addHead), &e.Data) 456 | body = "gzencode" 457 | 458 | // data will be written to redis - it implements the Stringer interface, redigo uses fmt to 459 | // print the data to redis. 460 | 461 | redisErr = redisClient.redisConnection(g.config.RedisInterface) 462 | if redisErr == nil { 463 | _, doErr := redisClient.conn.Do("SETEX", hash, g.config.RedisExpireSeconds, data) 464 | if doErr == nil { 465 | body = "redis" // the backend system will know to look in redis for the message data 466 | data.clear() // blank 467 | } 468 | } else { 469 | Log().WithError(redisErr).Warn("Error while connecting redis") 470 | } 471 | 472 | vals = []interface{}{} // clear the vals 473 | vals = append(vals, 474 | trimToLimit(to, 255), 475 | trimToLimit(e.MailFrom.String(), 255), 476 | trimToLimit(e.Subject, 255), 477 | body, 478 | data.String(), 479 | hash, 480 | trimToLimit(to, 255), 481 | e.RemoteIP, 482 | trimToLimit(e.MailFrom.String(), 255), 483 | e.TLS) 484 | // give the values to a random query batcher 485 | feeders[rand.Intn(len(feeders))] <- vals 486 | return p.Process(e, task) 487 | 488 | } else { 489 | return p.Process(e, task) 490 | } 491 | }) 492 | } 493 | } 494 | -------------------------------------------------------------------------------- /backends/gateway.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "sync" 8 | "time" 9 | 10 | "runtime/debug" 11 | "strings" 12 | 13 | "github.com/flashmob/go-guerrilla/log" 14 | "github.com/flashmob/go-guerrilla/mail" 15 | "github.com/flashmob/go-guerrilla/response" 16 | ) 17 | 18 | var ErrProcessorNotFound error 19 | 20 | // A backend gateway is a proxy that implements the Backend interface. 21 | // It is used to start multiple goroutine workers for saving mail, and then distribute email saving to the workers 22 | // via a channel. Shutting down via Shutdown() will stop all workers. 23 | // The rest of this program always talks to the backend via this gateway. 24 | type BackendGateway struct { 25 | // channel for distributing envelopes to workers 26 | conveyor chan *workerMsg 27 | 28 | // waits for backend workers to start/stop 29 | wg sync.WaitGroup 30 | workStoppers []chan bool 31 | processors []Processor 32 | validators []Processor 33 | 34 | // controls access to state 35 | sync.Mutex 36 | State backendState 37 | config BackendConfig 38 | gwConfig *GatewayConfig 39 | } 40 | 41 | type GatewayConfig struct { 42 | // WorkersSize controls how many concurrent workers to start. Defaults to 1 43 | WorkersSize int `json:"save_workers_size,omitempty"` 44 | // SaveProcess controls which processors to chain in a stack for saving email tasks 45 | SaveProcess string `json:"save_process,omitempty"` 46 | // ValidateProcess is like ProcessorStack, but for recipient validation tasks 47 | ValidateProcess string `json:"validate_process,omitempty"` 48 | // TimeoutSave is duration before timeout when saving an email, eg "29s" 49 | TimeoutSave string `json:"gw_save_timeout,omitempty"` 50 | // TimeoutValidateRcpt duration before timeout when validating a recipient, eg "1s" 51 | TimeoutValidateRcpt string `json:"gw_val_rcpt_timeout,omitempty"` 52 | } 53 | 54 | // workerMsg is what get placed on the BackendGateway.saveMailChan channel 55 | type workerMsg struct { 56 | // The email data 57 | e *mail.Envelope 58 | // notifyMe is used to notify the gateway of workers finishing their processing 59 | notifyMe chan *notifyMsg 60 | // select the task type 61 | task SelectTask 62 | } 63 | 64 | type backendState int 65 | 66 | // possible values for state 67 | const ( 68 | BackendStateNew backendState = iota 69 | BackendStateRunning 70 | BackendStateShuttered 71 | BackendStateError 72 | BackendStateInitialized 73 | 74 | // default timeout for saving email, if 'gw_save_timeout' not present in config 75 | saveTimeout = time.Second * 30 76 | // default timeout for validating rcpt to, if 'gw_val_rcpt_timeout' not present in config 77 | validateRcptTimeout = time.Second * 5 78 | defaultProcessor = "Debugger" 79 | ) 80 | 81 | func (s backendState) String() string { 82 | switch s { 83 | case BackendStateNew: 84 | return "NewState" 85 | case BackendStateRunning: 86 | return "RunningState" 87 | case BackendStateShuttered: 88 | return "ShutteredState" 89 | case BackendStateError: 90 | return "ErrorSate" 91 | case BackendStateInitialized: 92 | return "InitializedState" 93 | } 94 | return strconv.Itoa(int(s)) 95 | } 96 | 97 | // New makes a new default BackendGateway backend, and initializes it using 98 | // backendConfig and stores the logger 99 | func New(backendConfig BackendConfig, l log.Logger) (Backend, error) { 100 | Svc.SetMainlog(l) 101 | gateway := &BackendGateway{} 102 | err := gateway.Initialize(backendConfig) 103 | if err != nil { 104 | return nil, fmt.Errorf("error while initializing the backend: %s", err) 105 | } 106 | // keep the config known to be good. 107 | gateway.config = backendConfig 108 | 109 | b = Backend(gateway) 110 | return b, nil 111 | } 112 | 113 | var workerMsgPool = sync.Pool{ 114 | // if not available, then create a new one 115 | New: func() interface{} { 116 | return &workerMsg{} 117 | }, 118 | } 119 | 120 | // reset resets a workerMsg that has been borrowed from the pool 121 | func (w *workerMsg) reset(e *mail.Envelope, task SelectTask) { 122 | if w.notifyMe == nil { 123 | w.notifyMe = make(chan *notifyMsg) 124 | } 125 | w.e = e 126 | w.task = task 127 | } 128 | 129 | // Process distributes an envelope to one of the backend workers with a TaskSaveMail task 130 | func (gw *BackendGateway) Process(e *mail.Envelope) Result { 131 | if gw.State != BackendStateRunning { 132 | return NewResult(response.Canned.FailBackendNotRunning, response.SP, gw.State) 133 | } 134 | // borrow a workerMsg from the pool 135 | workerMsg := workerMsgPool.Get().(*workerMsg) 136 | workerMsg.reset(e, TaskSaveMail) 137 | // place on the channel so that one of the save mail workers can pick it up 138 | gw.conveyor <- workerMsg 139 | // wait for the save to complete 140 | // or timeout 141 | select { 142 | case status := <-workerMsg.notifyMe: 143 | // email saving transaction completed 144 | if status.result == BackendResultOK && status.queuedID != "" { 145 | return NewResult(response.Canned.SuccessMessageQueued, response.SP, status.queuedID) 146 | } 147 | 148 | // A custom result, there was probably an error, if so, log it 149 | if status.result != nil { 150 | if status.err != nil { 151 | Log().Error(status.err) 152 | } 153 | return status.result 154 | } 155 | 156 | // if there was no result, but there's an error, then make a new result from the error 157 | if status.err != nil { 158 | if _, err := strconv.Atoi(status.err.Error()[:3]); err != nil { 159 | return NewResult(response.Canned.FailBackendTransaction, response.SP, status.err) 160 | } 161 | return NewResult(status.err) 162 | } 163 | 164 | // both result & error are nil (should not happen) 165 | err := errors.New("no response from backend - processor did not return a result or an error") 166 | Log().Error(err) 167 | return NewResult(response.Canned.FailBackendTransaction, response.SP, err) 168 | 169 | case <-time.After(gw.saveTimeout()): 170 | Log().Error("Backend has timed out while saving email") 171 | e.Lock() // lock the envelope - it's still processing here, we don't want the server to recycle it 172 | go func() { 173 | // keep waiting for the backend to finish processing 174 | <-workerMsg.notifyMe 175 | e.Unlock() 176 | workerMsgPool.Put(workerMsg) 177 | }() 178 | return NewResult(response.Canned.FailBackendTimeout) 179 | } 180 | } 181 | 182 | // ValidateRcpt asks one of the workers to validate the recipient 183 | // Only the last recipient appended to e.RcptTo will be validated. 184 | func (gw *BackendGateway) ValidateRcpt(e *mail.Envelope) RcptError { 185 | if gw.State != BackendStateRunning { 186 | return StorageNotAvailable 187 | } 188 | if _, ok := gw.validators[0].(NoopProcessor); ok { 189 | // no validator processors configured 190 | return nil 191 | } 192 | // place on the channel so that one of the save mail workers can pick it up 193 | workerMsg := workerMsgPool.Get().(*workerMsg) 194 | workerMsg.reset(e, TaskValidateRcpt) 195 | gw.conveyor <- workerMsg 196 | // wait for the validation to complete 197 | // or timeout 198 | select { 199 | case status := <-workerMsg.notifyMe: 200 | workerMsgPool.Put(workerMsg) 201 | if status.err != nil { 202 | return status.err 203 | } 204 | return nil 205 | 206 | case <-time.After(gw.validateRcptTimeout()): 207 | e.Lock() 208 | go func() { 209 | <-workerMsg.notifyMe 210 | e.Unlock() 211 | workerMsgPool.Put(workerMsg) 212 | Log().Error("Backend has timed out while validating rcpt") 213 | }() 214 | return StorageTimeout 215 | } 216 | } 217 | 218 | // Shutdown shuts down the backend and leaves it in BackendStateShuttered state 219 | func (gw *BackendGateway) Shutdown() error { 220 | gw.Lock() 221 | defer gw.Unlock() 222 | if gw.State != BackendStateShuttered { 223 | // send a signal to all workers 224 | gw.stopWorkers() 225 | // wait for workers to stop 226 | gw.wg.Wait() 227 | // call shutdown on all processor shutdowners 228 | if err := Svc.shutdown(); err != nil { 229 | return err 230 | } 231 | gw.State = BackendStateShuttered 232 | } 233 | return nil 234 | } 235 | 236 | // Reinitialize initializes the gateway with the existing config after it was shutdown 237 | func (gw *BackendGateway) Reinitialize() error { 238 | if gw.State != BackendStateShuttered { 239 | return errors.New("backend must be in BackendStateshuttered state to Reinitialize") 240 | } 241 | // clear the Initializers and Shutdowners 242 | Svc.reset() 243 | 244 | err := gw.Initialize(gw.config) 245 | if err != nil { 246 | fmt.Println("reinitialize to ", gw.config, err) 247 | return fmt.Errorf("error while initializing the backend: %s", err) 248 | } 249 | 250 | return err 251 | } 252 | 253 | // newStack creates a new Processor by chaining multiple Processors in a call stack 254 | // Decorators are functions of Decorator type, source files prefixed with p_* 255 | // Each decorator does a specific task during the processing stage. 256 | // This function uses the config value save_process or validate_process to figure out which Decorator to use 257 | func (gw *BackendGateway) newStack(stackConfig string) (Processor, error) { 258 | var decorators []Decorator 259 | cfg := strings.ToLower(strings.TrimSpace(stackConfig)) 260 | if len(cfg) == 0 { 261 | //cfg = strings.ToLower(defaultProcessor) 262 | return NoopProcessor{}, nil 263 | } 264 | items := strings.Split(cfg, "|") 265 | for i := range items { 266 | name := items[len(items)-1-i] // reverse order, since decorators are stacked 267 | if makeFunc, ok := processors[name]; ok { 268 | decorators = append(decorators, makeFunc()) 269 | } else { 270 | ErrProcessorNotFound = fmt.Errorf("processor [%s] not found", name) 271 | return nil, ErrProcessorNotFound 272 | } 273 | } 274 | // build the call-stack of decorators 275 | p := Decorate(DefaultProcessor{}, decorators...) 276 | return p, nil 277 | } 278 | 279 | // loadConfig loads the config for the GatewayConfig 280 | func (gw *BackendGateway) loadConfig(cfg BackendConfig) error { 281 | configType := BaseConfig(&GatewayConfig{}) 282 | // Note: treat config values as immutable 283 | // if you need to change a config value, change in the file then 284 | // send a SIGHUP 285 | bcfg, err := Svc.ExtractConfig(cfg, configType) 286 | if err != nil { 287 | return err 288 | } 289 | gw.gwConfig = bcfg.(*GatewayConfig) 290 | return nil 291 | } 292 | 293 | // Initialize builds the workers and initializes each one 294 | func (gw *BackendGateway) Initialize(cfg BackendConfig) error { 295 | gw.Lock() 296 | defer gw.Unlock() 297 | if gw.State != BackendStateNew && gw.State != BackendStateShuttered { 298 | return errors.New("can only Initialize in BackendStateNew or BackendStateShuttered state") 299 | } 300 | err := gw.loadConfig(cfg) 301 | if err != nil { 302 | gw.State = BackendStateError 303 | return err 304 | } 305 | workersSize := gw.workersSize() 306 | if workersSize < 1 { 307 | gw.State = BackendStateError 308 | return errors.New("must have at least 1 worker") 309 | } 310 | gw.processors = make([]Processor, 0) 311 | gw.validators = make([]Processor, 0) 312 | for i := 0; i < workersSize; i++ { 313 | p, err := gw.newStack(gw.gwConfig.SaveProcess) 314 | if err != nil { 315 | gw.State = BackendStateError 316 | return err 317 | } 318 | gw.processors = append(gw.processors, p) 319 | 320 | v, err := gw.newStack(gw.gwConfig.ValidateProcess) 321 | if err != nil { 322 | gw.State = BackendStateError 323 | return err 324 | } 325 | gw.validators = append(gw.validators, v) 326 | } 327 | // initialize processors 328 | if err := Svc.initialize(cfg); err != nil { 329 | gw.State = BackendStateError 330 | return err 331 | } 332 | if gw.conveyor == nil { 333 | gw.conveyor = make(chan *workerMsg, workersSize) 334 | } 335 | // ready to start 336 | gw.State = BackendStateInitialized 337 | return nil 338 | } 339 | 340 | // Start starts the worker goroutines, assuming it has been initialized or shuttered before 341 | func (gw *BackendGateway) Start() error { 342 | gw.Lock() 343 | defer gw.Unlock() 344 | if gw.State == BackendStateInitialized || gw.State == BackendStateShuttered { 345 | // we start our workers 346 | workersSize := gw.workersSize() 347 | // make our slice of channels for stopping 348 | gw.workStoppers = make([]chan bool, 0) 349 | // set the wait group 350 | gw.wg.Add(workersSize) 351 | 352 | for i := 0; i < workersSize; i++ { 353 | stop := make(chan bool) 354 | go func(workerId int, stop chan bool) { 355 | // blocks here until the worker exits 356 | for { 357 | state := gw.workDispatcher( 358 | gw.conveyor, 359 | gw.processors[workerId], 360 | gw.validators[workerId], 361 | workerId+1, 362 | stop) 363 | // keep running after panic 364 | if state != dispatcherStatePanic { 365 | break 366 | } 367 | } 368 | gw.wg.Done() 369 | }(i, stop) 370 | gw.workStoppers = append(gw.workStoppers, stop) 371 | } 372 | gw.State = BackendStateRunning 373 | return nil 374 | } else { 375 | return fmt.Errorf("cannot start backend because it's in %s state", gw.State) 376 | } 377 | } 378 | 379 | // workersSize gets the number of workers to use for saving email by reading the save_workers_size config value 380 | // Returns 1 if no config value was set 381 | func (gw *BackendGateway) workersSize() int { 382 | if gw.gwConfig.WorkersSize <= 0 { 383 | return 1 384 | } 385 | return gw.gwConfig.WorkersSize 386 | } 387 | 388 | // saveTimeout returns the maximum amount of seconds to wait before timing out a save processing task 389 | func (gw *BackendGateway) saveTimeout() time.Duration { 390 | if gw.gwConfig.TimeoutSave == "" { 391 | return saveTimeout 392 | } 393 | t, err := time.ParseDuration(gw.gwConfig.TimeoutSave) 394 | if err != nil { 395 | return saveTimeout 396 | } 397 | return t 398 | } 399 | 400 | // validateRcptTimeout returns the maximum amount of seconds to wait before timing out a recipient validation task 401 | func (gw *BackendGateway) validateRcptTimeout() time.Duration { 402 | if gw.gwConfig.TimeoutValidateRcpt == "" { 403 | return validateRcptTimeout 404 | } 405 | t, err := time.ParseDuration(gw.gwConfig.TimeoutValidateRcpt) 406 | if err != nil { 407 | return validateRcptTimeout 408 | } 409 | return t 410 | } 411 | 412 | type dispatcherState int 413 | 414 | const ( 415 | dispatcherStateStopped dispatcherState = iota 416 | dispatcherStateIdle 417 | dispatcherStateWorking 418 | dispatcherStateNotify 419 | dispatcherStatePanic 420 | ) 421 | 422 | func (gw *BackendGateway) workDispatcher( 423 | workIn chan *workerMsg, 424 | save Processor, 425 | validate Processor, 426 | workerId int, 427 | stop chan bool) (state dispatcherState) { 428 | 429 | var msg *workerMsg 430 | 431 | defer func() { 432 | 433 | // panic recovery mechanism: it may panic when processing 434 | // since processors may call arbitrary code, some may be 3rd party / unstable 435 | // we need to detect the panic, and notify the backend that it failed & unlock the envelope 436 | if r := recover(); r != nil { 437 | Log().Error("worker recovered from panic:", r, string(debug.Stack())) 438 | 439 | if state == dispatcherStateWorking { 440 | msg.notifyMe <- ¬ifyMsg{err: errors.New("storage failed")} 441 | } 442 | state = dispatcherStatePanic 443 | return 444 | } 445 | // state is dispatcherStateStopped if it reached here 446 | 447 | }() 448 | state = dispatcherStateIdle 449 | Log().Infof("processing worker started (#%d)", workerId) 450 | for { 451 | select { 452 | case <-stop: 453 | state = dispatcherStateStopped 454 | Log().Infof("stop signal for worker (#%d)", workerId) 455 | return 456 | case msg = <-workIn: 457 | state = dispatcherStateWorking // recovers from panic if in this state 458 | if msg.task == TaskSaveMail { 459 | result, err := save.Process(msg.e, msg.task) 460 | state = dispatcherStateNotify 461 | msg.notifyMe <- ¬ifyMsg{err: err, result: result, queuedID: msg.e.QueuedId} 462 | } else { 463 | result, err := validate.Process(msg.e, msg.task) 464 | state = dispatcherStateNotify 465 | msg.notifyMe <- ¬ifyMsg{err: err, result: result} 466 | } 467 | } 468 | state = dispatcherStateIdle 469 | } 470 | } 471 | 472 | // stopWorkers sends a signal to all workers to stop 473 | func (gw *BackendGateway) stopWorkers() { 474 | for i := range gw.workStoppers { 475 | gw.workStoppers[i] <- true 476 | } 477 | } 478 | --------------------------------------------------------------------------------