diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edf3ce1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +gopath +.envrc +bin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d710520 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PKG:=github.com/kayrus/smtp-tester +APP_NAME:=smtp-tester +PWD:=$(shell pwd) +UID:=$(shell id -u) +VERSION:=$(shell git describe --tags --always --dirty="-dev") +LDFLAGS:=-X $(PKG)/pkg.Version=$(VERSION) + +export CGO_ENABLED:=0 + +build: fmt linux darwin windows + +linux: + GOOS=linux go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(APP_NAME) ./cmd + +darwin: + GOOS=darwin go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(APP_NAME)_darwin ./cmd + +windows: + GOOS=windows go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(APP_NAME).exe ./cmd + +docker: + docker run -ti --rm -e GOCACHE=/tmp -v $(PWD):/$(APP_NAME) -u $(UID):$(UID) --workdir /$(APP_NAME) golang:latest make + +fmt: + gofmt -s -w cmd diff --git a/README.md b/README.md new file mode 100644 index 0000000..98a25c4 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# smtp-tester + +A tool to run SMTP stress tests + +## Help + +``` +Usage of smtp-tester: + -debug + show debug logs + -from string + Envelope from sender address + -header-from string + Header from sender address, if empty defaults to --from + -max-mails uint + Limit the amount of emails, 0 means no limit + -password string + SMTP server password + -reuse-smtp + Reuse SMTP connection + -show-error + show error type on auth failure + -size uint + Message size in bytes (default 30720) + -smtp-host string + SMTP server address + -starttls + whether to require StartTLS (default true) + -subject string + Email subject (default "hello") + -threads uint + Whether to run an infinite loop with an amount of threads + -timeout uint + Timeout in seconds (default 3) + -to string + Recipient address + -username string + SMTP server username +``` + +## Example + +```sh +smtp-tester -max-mails 1000 \ + -from test@example.com \ + -to blackbox@example.com \ + -show-error \ + -smtp-host localhost:25 \ + -threads 100 \ + -username user \ + -password password \ + -size 10 \ + -timeout 30 \ + -starttls=false +``` diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..13cddad --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,429 @@ +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "io" + "log" + "math/rand" + "net" + "net/mail" + "os" + "os/signal" + "regexp" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +var ( + errReplace1 = regexp.MustCompile(`(.*) [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(.*)`) + errReplace2 = regexp.MustCompile(`(.*) [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+->[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(.*)`) + lck = &sync.RWMutex{} + errs = make(map[string]uint64) + totalReq = new(uint64) + totalErr = new(uint64) + fps = new(uint64) + ops = new(uint64) +) + +type config struct { + sync.Mutex + Debug bool + SMTPHost string + Username string + Password string + Subject string + From *mail.Address + HeaderFrom *mail.Address + To *mail.Address + ShowErr bool + StartTLS bool + Threads uint + Timeout time.Duration + Size uint + MaxMails uint64 + ReuseSMTP bool + Msg *strings.Reader + Auth sasl.Client + WG sync.WaitGroup + Client *smtp.Client +} + +func (cfg *config) trackErr(stage string, err error, limiter chan struct{}) { + atomic.AddUint64(fps, 1) + if limiter == nil { + log.Print(err) + os.Exit(1) + } + + if !cfg.ShowErr { + <-limiter + return + } + + switch err := err.(type) { + case *net.OpError: + // put all "connection reset by peer" errors under one group + if addr, ok := err.Source.(*net.TCPAddr); ok { + addr.Port = 65535 + } + case *smtp.SMTPError: + if err.Code == 554 { + if strings.Contains(err.Message, "dial tcp") { + err.Message = errReplace1.ReplaceAllString(err.Message, `$1 backend$2`) + } else { + err.Message = errReplace2.ReplaceAllString(err.Message, `$1 smtp:65535->backend$2`) + } + } + } + errType := fmt.Sprintf("%s: %s", stage, err) + lck.Lock() + errs[errType] += 1 + lck.Unlock() + + <-limiter +} + +func (cfg *config) sendMultiple(n uint64, limiter chan struct{}) { + defer cfg.WG.Done() + + atomic.AddUint64(ops, 1) + + // single email at once + cfg.Lock() + defer cfg.Unlock() + + c := cfg.Client + defer c.Reset() + err := c.Mail(cfg.From.Address, nil) + if err != nil { + cfg.trackErr("mail", err, limiter) + return + } + + err = c.Rcpt(cfg.To.Address) + if err != nil { + cfg.trackErr("rcpt", err, limiter) + return + } + + w, err := c.Data() + if err != nil { + cfg.trackErr("data", err, limiter) + return + } + + _, err = io.Copy(w, cfg.Msg) + if err != nil { + w.Close() + cfg.trackErr("copy", err, limiter) + return + } + + err = w.Close() + if err != nil { + cfg.trackErr("close", err, limiter) + return + } + + if limiter == nil { + return + } + + <-limiter +} + +func getClient(cfg *config) (*smtp.Client, error) { + conn, tlsConfig, err := initConn(cfg) + if err != nil { + return nil, err + } + + // Set custom timeouts + c := &smtp.Client{ + CommandTimeout: cfg.Timeout, + SubmissionTimeout: cfg.Timeout, + } + err = c.InitConn(conn) + if err != nil { + return nil, err + } + + hostname := "test" + err = c.Hello(hostname) + if err != nil { + return nil, err + } + + if _, ok := conn.(*tls.Conn); !ok && cfg.StartTLS { + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(tlsConfig); err != nil { + return nil, err + } + } else { + return nil, err + } + } + + if err = c.Auth(cfg.Auth); err != nil { + return nil, err + } + + return c, nil +} + +func (cfg *config) sendSingle(n uint64, limiter chan struct{}) { + defer cfg.WG.Done() + + atomic.AddUint64(ops, 1) + + conn, tlsConfig, err := initConn(cfg) + if err != nil { + cfg.trackErr("dial", err, limiter) + return + } + defer conn.Close() + + // Set custom timeouts + c := &smtp.Client{ + CommandTimeout: cfg.Timeout, + SubmissionTimeout: cfg.Timeout, + } + err = c.InitConn(conn) + if err != nil { + cfg.trackErr("init", err, limiter) + return + } + + defer c.Quit() + + hostname := fmt.Sprintf("test%d", n) + err = c.Hello(hostname) + if err != nil { + cfg.trackErr("hello", err, limiter) + return + } + + if _, ok := conn.(*tls.Conn); !ok && cfg.StartTLS { + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(tlsConfig); err != nil { + cfg.trackErr("starttls", err, limiter) + return + } + } else { + cfg.trackErr("starttls", fmt.Errorf("starttls is required"), limiter) + return + } + } + + if err = c.Auth(cfg.Auth); err != nil { + cfg.trackErr("auth", err, limiter) + return + } + + err = c.SendMail(cfg.From.Address, []string{cfg.To.Address}, cfg.Msg) + if err != nil { + cfg.trackErr("send", err, limiter) + return + } + + if limiter == nil { + return + } + + <-limiter +} + +func randStringBytes(n uint) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} + +func monitor(showErr bool) { + for { + select { + case <-time.After(1 * time.Second): + f := atomic.SwapUint64(fps, 0) + s := atomic.SwapUint64(ops, 0) + tS := atomic.AddUint64(totalReq, s) + tF := atomic.AddUint64(totalErr, f) + var perc uint64 + var tPerc uint64 + if s > 0 { + perc = 100 * f / s + } + if tS > 0 { + tPerc = 100 * tF / tS + } + log.Printf("%d rps, %d failed (%d%%)", s, f, perc) + log.Printf("total %d requests, %d successful, %d failed (%d%%)", tS, tS-tF, tF, tPerc) + if showErr { + lck.RLock() + for k, v := range errs { + log.Printf("ERROR: %s -> %d", k, v) + } + lck.RUnlock() + } + /* + if s == 0 { + log.Fatalf("stop") + } + */ + } + } +} + +func initConn(cfg *config) (net.Conn, *tls.Config, error) { + h, p, _ := net.SplitHostPort(cfg.SMTPHost) + if p == "465" { + tlsConfig := &tls.Config{ + ServerName: h, + } + tlsDialer := tls.Dialer{ + NetDialer: &net.Dialer{ + Timeout: cfg.Timeout, + }, + Config: tlsConfig, + } + conn, err := tlsDialer.Dial("tcp", cfg.SMTPHost) + return conn, tlsConfig, err + } + + tlsConfig := &tls.Config{ + ServerName: h, + } + conn, err := net.DialTimeout("tcp", cfg.SMTPHost, cfg.Timeout) + return conn, tlsConfig, err +} + +func main() { + var cfg config + var from, headerFrom, to string + var timeout uint + flag.StringVar(&cfg.SMTPHost, "smtp-host", "", "SMTP server address") + flag.StringVar(&cfg.Username, "username", "", "SMTP server username") + flag.StringVar(&cfg.Password, "password", "", "SMTP server password") + flag.StringVar(&cfg.Subject, "subject", "hello", "Email subject") + flag.StringVar(&from, "from", "", "Envelope from sender address") + flag.StringVar(&headerFrom, "header-from", "", "Header from sender address, if empty defaults to --from") + flag.StringVar(&to, "to", "", "Recipient address") + flag.UintVar(&cfg.Threads, "threads", 0, "Whether to run an infinite loop with an amount of threads") + flag.UintVar(&timeout, "timeout", 3, "Timeout in seconds") + flag.UintVar(&cfg.Size, "size", 30720, "Message size in bytes") + flag.Uint64Var(&cfg.MaxMails, "max-mails", 0, "Limit the amount of emails, 0 means no limit") + flag.BoolVar(&cfg.Debug, "debug", false, "show debug logs") + flag.BoolVar(&cfg.ShowErr, "show-error", false, "show error type on auth failure") + flag.BoolVar(&cfg.StartTLS, "starttls", true, "whether to require StartTLS") + flag.BoolVar(&cfg.ReuseSMTP, "reuse-smtp", false, "Reuse SMTP connection") + flag.Parse() + + // SMTP credentials + if cfg.SMTPHost == "" { + log.Fatalf("Please define --smtp-host argument") + } + + if cfg.Username == "" { + log.Fatalf("Please define --username argument") + } + + if cfg.Password == "" { + log.Fatalf("Please define --password argument") + } + + if cfg.ReuseSMTP && cfg.Threads != 1 { + log.Fatalf("Multiple threads with reusing SMTP connection is not supported") + } + + // Sender and recipient + var err error + cfg.From, err = mail.ParseAddress(from) + if err != nil { + log.Fatalf("Invalid --from argument: %s", err) + } + cfg.To, err = mail.ParseAddress(to) + if err != nil { + log.Fatalf("Invalid --to argument: %s", err) + } + if headerFrom != "" { + cfg.HeaderFrom, err = mail.ParseAddress(headerFrom) + if err != nil { + log.Fatalf("Invalid --header-from argument: %s", err) + } + } else { + cfg.HeaderFrom = cfg.From + } + + // other parameters + cfg.Timeout = time.Second * time.Duration(timeout) + cfg.Auth = sasl.NewPlainClient("", cfg.Username, cfg.Password) + cfg.Msg = strings.NewReader("To: " + cfg.To.Address + "\r\n" + + "From: " + cfg.HeaderFrom.String() + "\r\n" + + "Subject: " + cfg.Subject + "\r\n" + + "\r\n" + + randStringBytes(cfg.Size) + + "\r\n") + + var send func(n uint64, limiter chan struct{}) + if cfg.ReuseSMTP { + cfg.Client, err = getClient(&cfg) + if err != nil { + log.Fatalf("failed to initalize a client: %s", err) + } + log.Printf("initialized a client") + send = cfg.sendMultiple + } else { + send = cfg.sendSingle + } + + if cfg.Threads == 0 { + send(0, nil) + os.Exit(0) + } + + exit := make(chan os.Signal, 1) + signal.Notify(exit, os.Interrupt, syscall.SIGTERM) + + // stats monitor + go monitor(cfg.ShowErr) + + // main loop + var count uint64 + limiter := make(chan struct{}, cfg.Threads) + for { + select { + case <-exit: + log.Printf("Interrupted") + log.Printf("waiting for all threads to stop") + cfg.WG.Wait() + log.Printf("Sleeping for 3 seconds") + time.Sleep(3 * time.Second) + log.Printf("done") + return + default: + if cfg.MaxMails > 0 && count >= cfg.MaxMails { + // stop on reaching the max emails limit + cfg.WG.Wait() + log.Printf("Sleeping for 3 seconds") + time.Sleep(3 * time.Second) + log.Printf("done") + return + } + count++ + limiter <- struct{}{} + cfg.WG.Add(1) + go send(count, limiter) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e88abfa --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/kayrus/smtp-tester + +require ( + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.15.1-0.20211103212524-30169acc42e7 +) + +// workaround for https://github.com/emersion/go-smtp/pull/148 +replace github.com/emersion/go-smtp => github.com/kayrus/go-smtp v0.15.1-0.20211216174341-f5f4e119d8cc + +go 1.14 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3d41aee --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/kayrus/go-smtp v0.15.1-0.20211216174341-f5f4e119d8cc h1:boGHPdglOe9hx5vqcMyllF4XdbjqQacGK7U9Wu0xqSM= +github.com/kayrus/go-smtp v0.15.1-0.20211216174341-f5f4e119d8cc/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= diff --git a/vendor/github.com/emersion/go-sasl/.build.yml b/vendor/github.com/emersion/go-sasl/.build.yml new file mode 100644 index 0000000..daa6006 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/.build.yml @@ -0,0 +1,19 @@ +image: alpine/latest +packages: + - go + # Required by codecov + - bash + - findutils +sources: + - https://github.com/emersion/go-sasl +tasks: + - build: | + cd go-sasl + go build -v ./... + - test: | + cd go-sasl + go test -coverprofile=coverage.txt -covermode=atomic ./... + - upload-coverage: | + cd go-sasl + export CODECOV_TOKEN=3f257f71-a128-4834-8f68-2b534e9f4cb1 + curl -s https://codecov.io/bash | bash diff --git a/vendor/github.com/emersion/go-sasl/.gitignore b/vendor/github.com/emersion/go-sasl/.gitignore new file mode 100644 index 0000000..daf913b --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/.gitignore @@ -0,0 +1,24 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/vendor/github.com/emersion/go-sasl/LICENSE b/vendor/github.com/emersion/go-sasl/LICENSE new file mode 100644 index 0000000..dc1922e --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 emersion + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/emersion/go-sasl/README.md b/vendor/github.com/emersion/go-sasl/README.md new file mode 100644 index 0000000..1f8a682 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/README.md @@ -0,0 +1,17 @@ +# go-sasl + +[![GoDoc](https://godoc.org/github.com/emersion/go-sasl?status.svg)](https://godoc.org/github.com/emersion/go-sasl) +[![Build Status](https://travis-ci.org/emersion/go-sasl.svg?branch=master)](https://travis-ci.org/emersion/go-sasl) + +A [SASL](https://tools.ietf.org/html/rfc4422) library written in Go. + +Implemented mechanisms: +* [ANONYMOUS](https://tools.ietf.org/html/rfc4505) +* [EXTERNAL](https://tools.ietf.org/html/rfc4422#appendix-A) +* [LOGIN](https://tools.ietf.org/html/draft-murchison-sasl-login-00) (obsolete, use PLAIN instead) +* [PLAIN](https://tools.ietf.org/html/rfc4616) +* [OAUTHBEARER](https://tools.ietf.org/html/rfc7628) + +## License + +MIT diff --git a/vendor/github.com/emersion/go-sasl/anonymous.go b/vendor/github.com/emersion/go-sasl/anonymous.go new file mode 100644 index 0000000..8ccb817 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/anonymous.go @@ -0,0 +1,56 @@ +package sasl + +// The ANONYMOUS mechanism name. +const Anonymous = "ANONYMOUS" + +type anonymousClient struct { + Trace string +} + +func (c *anonymousClient) Start() (mech string, ir []byte, err error) { + mech = Anonymous + ir = []byte(c.Trace) + return +} + +func (c *anonymousClient) Next(challenge []byte) (response []byte, err error) { + return nil, ErrUnexpectedServerChallenge +} + +// A client implementation of the ANONYMOUS authentication mechanism, as +// described in RFC 4505. +func NewAnonymousClient(trace string) Client { + return &anonymousClient{trace} +} + +// Get trace information from clients logging in anonymously. +type AnonymousAuthenticator func(trace string) error + +type anonymousServer struct { + done bool + authenticate AnonymousAuthenticator +} + +func (s *anonymousServer) Next(response []byte) (challenge []byte, done bool, err error) { + if s.done { + err = ErrUnexpectedClientResponse + return + } + + // No initial response, send an empty challenge + if response == nil { + return []byte{}, false, nil + } + + s.done = true + + err = s.authenticate(string(response)) + done = true + return +} + +// A server implementation of the ANONYMOUS authentication mechanism, as +// described in RFC 4505. +func NewAnonymousServer(authenticator AnonymousAuthenticator) Server { + return &anonymousServer{authenticate: authenticator} +} diff --git a/vendor/github.com/emersion/go-sasl/external.go b/vendor/github.com/emersion/go-sasl/external.go new file mode 100644 index 0000000..da070c8 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/external.go @@ -0,0 +1,26 @@ +package sasl + +// The EXTERNAL mechanism name. +const External = "EXTERNAL" + +type externalClient struct { + Identity string +} + +func (a *externalClient) Start() (mech string, ir []byte, err error) { + mech = External + ir = []byte(a.Identity) + return +} + +func (a *externalClient) Next(challenge []byte) (response []byte, err error) { + return nil, ErrUnexpectedServerChallenge +} + +// An implementation of the EXTERNAL authentication mechanism, as described in +// RFC 4422. Authorization identity may be left blank to indicate that the +// client is requesting to act as the identity associated with the +// authentication credentials. +func NewExternalClient(identity string) Client { + return &externalClient{identity} +} diff --git a/vendor/github.com/emersion/go-sasl/go.mod b/vendor/github.com/emersion/go-sasl/go.mod new file mode 100644 index 0000000..dc3c9a4 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/go.mod @@ -0,0 +1,3 @@ +module github.com/emersion/go-sasl + +go 1.12 diff --git a/vendor/github.com/emersion/go-sasl/login.go b/vendor/github.com/emersion/go-sasl/login.go new file mode 100644 index 0000000..3847ee1 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/login.go @@ -0,0 +1,89 @@ +package sasl + +import ( + "bytes" +) + +// The LOGIN mechanism name. +const Login = "LOGIN" + +var expectedChallenge = []byte("Password:") + +type loginClient struct { + Username string + Password string +} + +func (a *loginClient) Start() (mech string, ir []byte, err error) { + mech = "LOGIN" + ir = []byte(a.Username) + return +} + +func (a *loginClient) Next(challenge []byte) (response []byte, err error) { + if bytes.Compare(challenge, expectedChallenge) != 0 { + return nil, ErrUnexpectedServerChallenge + } else { + return []byte(a.Password), nil + } +} + +// A client implementation of the LOGIN authentication mechanism for SMTP, +// as described in http://www.iana.org/go/draft-murchison-sasl-login +// +// It is considered obsolete, and should not be used when other mechanisms are +// available. For plaintext password authentication use PLAIN mechanism. +func NewLoginClient(username, password string) Client { + return &loginClient{username, password} +} + +// Authenticates users with an username and a password. +type LoginAuthenticator func(username, password string) error + +type loginState int + +const ( + loginNotStarted loginState = iota + loginWaitingUsername + loginWaitingPassword +) + +type loginServer struct { + state loginState + username, password string + authenticate LoginAuthenticator +} + +// A server implementation of the LOGIN authentication mechanism, as described +// in https://tools.ietf.org/html/draft-murchison-sasl-login-00. +// +// LOGIN is obsolete and should only be enabled for legacy clients that cannot +// be updated to use PLAIN. +func NewLoginServer(authenticator LoginAuthenticator) Server { + return &loginServer{authenticate: authenticator} +} + +func (a *loginServer) Next(response []byte) (challenge []byte, done bool, err error) { + switch a.state { + case loginNotStarted: + // Check for initial response field, as per RFC4422 section 3 + if response == nil { + challenge = []byte("Username:") + break + } + a.state++ + fallthrough + case loginWaitingUsername: + a.username = string(response) + challenge = []byte("Password:") + case loginWaitingPassword: + a.password = string(response) + err = a.authenticate(a.username, a.password) + done = true + default: + err = ErrUnexpectedClientResponse + } + + a.state++ + return +} diff --git a/vendor/github.com/emersion/go-sasl/oauthbearer.go b/vendor/github.com/emersion/go-sasl/oauthbearer.go new file mode 100644 index 0000000..a0639b1 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/oauthbearer.go @@ -0,0 +1,191 @@ +package sasl + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +// The OAUTHBEARER mechanism name. +const OAuthBearer = "OAUTHBEARER" + +type OAuthBearerError struct { + Status string `json:"status"` + Schemes string `json:"schemes"` + Scope string `json:"scope"` +} + +type OAuthBearerOptions struct { + Username string + Token string + Host string + Port int +} + +// Implements error +func (err *OAuthBearerError) Error() string { + return fmt.Sprintf("OAUTHBEARER authentication error (%v)", err.Status) +} + +type oauthBearerClient struct { + OAuthBearerOptions +} + +func (a *oauthBearerClient) Start() (mech string, ir []byte, err error) { + mech = OAuthBearer + var str = "n,a=" + a.Username + "," + + if a.Host != "" { + str += "\x01host=" + a.Host + } + + if a.Port != 0 { + str += "\x01port=" + strconv.Itoa(a.Port) + } + str += "\x01auth=Bearer " + a.Token + "\x01\x01" + ir = []byte(str) + return +} + +func (a *oauthBearerClient) Next(challenge []byte) ([]byte, error) { + authBearerErr := &OAuthBearerError{} + if err := json.Unmarshal(challenge, authBearerErr); err != nil { + return nil, err + } else { + return nil, authBearerErr + } +} + +// An implementation of the OAUTHBEARER authentication mechanism, as +// described in RFC 7628. +func NewOAuthBearerClient(opt *OAuthBearerOptions) Client { + return &oauthBearerClient{*opt} +} + +type OAuthBearerAuthenticator func(opts OAuthBearerOptions) *OAuthBearerError + +type oauthBearerServer struct { + done bool + failErr error + authenticate OAuthBearerAuthenticator +} + +func (a *oauthBearerServer) fail(descr string) ([]byte, bool, error) { + blob, err := json.Marshal(OAuthBearerError{ + Status: "invalid_request", + Schemes: "bearer", + }) + if err != nil { + panic(err) // wtf + } + a.failErr = errors.New(descr) + return blob, false, nil +} + +func (a *oauthBearerServer) Next(response []byte) (challenge []byte, done bool, err error) { + // Per RFC, we cannot just send an error, we need to return JSON-structured + // value as a challenge and then after getting dummy response from the + // client stop the exchange. + if a.failErr != nil { + // Server libraries (go-smtp, go-imap) will not call Next on + // protocol-specific SASL cancel response ('*'). However, GS2 (and + // indirectly OAUTHBEARER) defines a protocol-independent way to do so + // using 0x01. + if len(response) != 1 && response[0] != 0x01 { + return nil, true, errors.New("unexpected response") + } + return nil, true, a.failErr + } + + if a.done { + err = ErrUnexpectedClientResponse + return + } + + // Generate empty challenge. + if response == nil { + return []byte{}, false, nil + } + + a.done = true + + // Cut n,a=username,\x01host=...\x01auth=... + // into + // n + // a=username + // \x01host=...\x01auth=...\x01\x01 + parts := bytes.SplitN(response, []byte{','}, 3) + if len(parts) != 3 { + return a.fail("Invalid response") + } + if !bytes.Equal(parts[0], []byte{'n'}) { + return a.fail("Invalid response, missing 'n'") + } + opts := OAuthBearerOptions{} + if !bytes.HasPrefix(parts[1], []byte("a=")) { + return a.fail("Invalid response, missing 'a'") + } + opts.Username = string(bytes.TrimPrefix(parts[1], []byte("a="))) + + // Cut \x01host=...\x01auth=...\x01\x01 + // into + // *empty* + // host=... + // auth=... + // *empty* + // + // Note that this code does not do a lot of checks to make sure the input + // follows the exact format specified by RFC. + params := bytes.Split(parts[2], []byte{0x01}) + for _, p := range params { + // Skip empty fields (one at start and end). + if len(p) == 0 { + continue + } + + pParts := bytes.SplitN(p, []byte{'='}, 2) + if len(pParts) != 2 { + return a.fail("Invalid response, missing '='") + } + + switch string(pParts[0]) { + case "host": + opts.Host = string(pParts[1]) + case "port": + port, err := strconv.ParseUint(string(pParts[1]), 10, 16) + if err != nil { + return a.fail("Invalid response, malformed 'port' value") + } + opts.Port = int(port) + case "auth": + const prefix = "bearer " + strValue := string(pParts[1]) + // Token type is case-insensitive. + if !strings.HasPrefix(strings.ToLower(strValue), prefix) { + return a.fail("Unsupported token type") + } + opts.Token = strValue[len(prefix):] + default: + return a.fail("Invalid response, unknown parameter: " + string(pParts[0])) + } + } + + authzErr := a.authenticate(opts) + if authzErr != nil { + blob, err := json.Marshal(authzErr) + if err != nil { + panic(err) // wtf + } + a.failErr = authzErr + return blob, false, nil + } + + return nil, true, nil +} + +func NewOAuthBearerServer(auth OAuthBearerAuthenticator) Server { + return &oauthBearerServer{authenticate: auth} +} diff --git a/vendor/github.com/emersion/go-sasl/plain.go b/vendor/github.com/emersion/go-sasl/plain.go new file mode 100644 index 0000000..344ed17 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/plain.go @@ -0,0 +1,77 @@ +package sasl + +import ( + "bytes" + "errors" +) + +// The PLAIN mechanism name. +const Plain = "PLAIN" + +type plainClient struct { + Identity string + Username string + Password string +} + +func (a *plainClient) Start() (mech string, ir []byte, err error) { + mech = "PLAIN" + ir = []byte(a.Identity + "\x00" + a.Username + "\x00" + a.Password) + return +} + +func (a *plainClient) Next(challenge []byte) (response []byte, err error) { + return nil, ErrUnexpectedServerChallenge +} + +// A client implementation of the PLAIN authentication mechanism, as described +// in RFC 4616. Authorization identity may be left blank to indicate that it is +// the same as the username. +func NewPlainClient(identity, username, password string) Client { + return &plainClient{identity, username, password} +} + +// Authenticates users with an identity, a username and a password. If the +// identity is left blank, it indicates that it is the same as the username. +// If identity is not empty and the server doesn't support it, an error must be +// returned. +type PlainAuthenticator func(identity, username, password string) error + +type plainServer struct { + done bool + authenticate PlainAuthenticator +} + +func (a *plainServer) Next(response []byte) (challenge []byte, done bool, err error) { + if a.done { + err = ErrUnexpectedClientResponse + return + } + + // No initial response, send an empty challenge + if response == nil { + return []byte{}, false, nil + } + + a.done = true + + parts := bytes.Split(response, []byte("\x00")) + if len(parts) != 3 { + err = errors.New("Invalid response") + return + } + + identity := string(parts[0]) + username := string(parts[1]) + password := string(parts[2]) + + err = a.authenticate(identity, username, password) + done = true + return +} + +// A server implementation of the PLAIN authentication mechanism, as described +// in RFC 4616. +func NewPlainServer(authenticator PlainAuthenticator) Server { + return &plainServer{authenticate: authenticator} +} diff --git a/vendor/github.com/emersion/go-sasl/sasl.go b/vendor/github.com/emersion/go-sasl/sasl.go new file mode 100644 index 0000000..c209144 --- /dev/null +++ b/vendor/github.com/emersion/go-sasl/sasl.go @@ -0,0 +1,45 @@ +// Library for Simple Authentication and Security Layer (SASL) defined in RFC 4422. +package sasl + +// Note: +// Most of this code was copied, with some modifications, from net/smtp. It +// would be better if Go provided a standard package (e.g. crypto/sasl) that +// could be shared by SMTP, IMAP, and other packages. + +import ( + "errors" +) + +// Common SASL errors. +var ( + ErrUnexpectedClientResponse = errors.New("sasl: unexpected client response") + ErrUnexpectedServerChallenge = errors.New("sasl: unexpected server challenge") +) + +// Client interface to perform challenge-response authentication. +type Client interface { + // Begins SASL authentication with the server. It returns the + // authentication mechanism name and "initial response" data (if required by + // the selected mechanism). A non-nil error causes the client to abort the + // authentication attempt. + // + // A nil ir value is different from a zero-length value. The nil value + // indicates that the selected mechanism does not use an initial response, + // while a zero-length value indicates an empty initial response, which must + // be sent to the server. + Start() (mech string, ir []byte, err error) + + // Continues challenge-response authentication. A non-nil error causes + // the client to abort the authentication attempt. + Next(challenge []byte) (response []byte, err error) +} + +// Server interface to perform challenge-response authentication. +type Server interface { + // Begins or continues challenge-response authentication. If the client + // supplies an initial response, response is non-nil. + // + // If the authentication is finished, done is set to true. If the + // authentication has failed, an error is returned. + Next(response []byte) (challenge []byte, done bool, err error) +} diff --git a/vendor/github.com/emersion/go-smtp/.build.yml b/vendor/github.com/emersion/go-smtp/.build.yml new file mode 100644 index 0000000..46854b9 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/.build.yml @@ -0,0 +1,17 @@ +image: alpine/edge +packages: + - go +sources: + - https://github.com/emersion/go-smtp +artifacts: + - coverage.html +tasks: + - build: | + cd go-smtp + go build -v ./... + - test: | + cd go-smtp + go test -coverprofile=coverage.txt -covermode=atomic ./... + - coverage: | + cd go-smtp + go tool cover -html=coverage.txt -o ~/coverage.html diff --git a/vendor/github.com/emersion/go-smtp/.gitignore b/vendor/github.com/emersion/go-smtp/.gitignore new file mode 100644 index 0000000..dc3e55d --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/.gitignore @@ -0,0 +1,26 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +/main.go diff --git a/vendor/github.com/emersion/go-smtp/LICENSE b/vendor/github.com/emersion/go-smtp/LICENSE new file mode 100644 index 0000000..92ce700 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/LICENSE @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) 2010 The Go Authors +Copyright (c) 2014 Gleez Technologies +Copyright (c) 2016 emersion +Copyright (c) 2016 Proton Technologies AG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/emersion/go-smtp/README.md b/vendor/github.com/emersion/go-smtp/README.md new file mode 100644 index 0000000..4cbd1fc --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/README.md @@ -0,0 +1,237 @@ +# go-smtp + +[![godocs.io](https://godocs.io/github.com/emersion/go-smtp?status.svg)](https://godocs.io/github.com/emersion/go-smtp) +[![builds.sr.ht status](https://builds.sr.ht/~emersion/go-smtp/commits.svg)](https://builds.sr.ht/~emersion/go-smtp/commits?) + +An ESMTP client and server library written in Go. + +## Features + +* ESMTP client & server implementing [RFC 5321](https://tools.ietf.org/html/rfc5321) +* Support for SMTP [AUTH](https://tools.ietf.org/html/rfc4954) and [PIPELINING](https://tools.ietf.org/html/rfc2920) +* UTF-8 support for subject and message +* [LMTP](https://tools.ietf.org/html/rfc2033) support + +## Usage + +### Client + +```go +package main + +import ( + "log" + "strings" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +func main() { + // Setup authentication information. + auth := sasl.NewPlainClient("", "user@example.com", "password") + + // Connect to the server, authenticate, set the sender and recipient, + // and send the email all in one step. + to := []string{"recipient@example.net"} + msg := strings.NewReader("To: recipient@example.net\r\n" + + "Subject: discount Gophers!\r\n" + + "\r\n" + + "This is the email body.\r\n") + err := smtp.SendMail("mail.example.com:25", auth, "sender@example.org", to, msg) + if err != nil { + log.Fatal(err) + } +} +``` + +If you need more control, you can use `Client` instead. For example, if you +want to send an email via a server without TLS or auth support, you can do +something like this: + +```go +package main + +import ( + "log" + "strings" + + "github.com/emersion/go-smtp" +) + +func main() { + // Setup an unencrypted connection to a local mail server. + c, err := smtp.Dial("localhost:25") + if err != nil { + return err + } + defer c.Close() + + // Set the sender and recipient, and send the email all in one step. + to := []string{"recipient@example.net"} + msg := strings.NewReader("To: recipient@example.net\r\n" + + "Subject: discount Gophers!\r\n" + + "\r\n" + + "This is the email body.\r\n") + err := c.SendMail("sender@example.org", to, msg) + if err != nil { + log.Fatal(err) + } +} +``` + +You can also set the timeout for the server greeting stage: + +```go +package main + +import ( + "log" + "net" + "strings" + "time" + + "github.com/emersion/go-smtp" +) + +var ( + timeout = 1 * time.Second + addr = "localhost:25" +) + +func main() { + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + log.Fatal(err) + } + defer conn.Close() + + // Set custom timeouts + c := &smtp.Client{ + CommandTimeout: timeout, + SubmissionTimeout: timeout, + } + err = c.InitConn(conn) + if err != nil { + log.Fatal(err) + } + + err = c.Hello("localhost") + if err != nil { + log.Fatal(err) + } + + // Set the sender and recipient, and send the email all in one step. + to := []string{"recipient@example.net"} + msg := strings.NewReader("To: recipient@example.net\r\n" + + "Subject: discount Gophers!\r\n" + + "\r\n" + + "This is the email body.\r\n") + err = c.SendMail("sender@example.org", to, msg) + if err != nil { + log.Fatal(err) + } +} +``` + +### Server + +```go +package main + +import ( + "errors" + "io" + "io/ioutil" + "log" + "time" + + "github.com/emersion/go-smtp" +) + +// The Backend implements SMTP server methods. +type Backend struct{} + +// Login handles a login command with username and password. +func (bkd *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { + if username != "username" || password != "password" { + return nil, errors.New("Invalid username or password") + } + return &Session{}, nil +} + +// AnonymousLogin requires clients to authenticate using SMTP AUTH before sending emails +func (bkd *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { + return nil, smtp.ErrAuthRequired +} + +// A Session is returned after successful login. +type Session struct{} + +func (s *Session) Mail(from string, opts *smtp.MailOptions) error { + log.Println("Mail from:", from) + return nil +} + +func (s *Session) Rcpt(to string) error { + log.Println("Rcpt to:", to) + return nil +} + +func (s *Session) Data(r io.Reader) error { + if b, err := ioutil.ReadAll(r); err != nil { + return err + } else { + log.Println("Data:", string(b)) + } + return nil +} + +func (s *Session) Reset() {} + +func (s *Session) Logout() error { + return nil +} + +func main() { + be := &Backend{} + + s := smtp.NewServer(be) + + s.Addr = ":1025" + s.Domain = "localhost" + s.ReadTimeout = 10 * time.Second + s.WriteTimeout = 10 * time.Second + s.MaxMessageBytes = 1024 * 1024 + s.MaxRecipients = 50 + s.AllowInsecureAuth = true + + log.Println("Starting server at", s.Addr) + if err := s.ListenAndServe(); err != nil { + log.Fatal(err) + } +} +``` + +You can use the server manually with `telnet`: +``` +$ telnet localhost 1025 +EHLO localhost +AUTH PLAIN +AHVzZXJuYW1lAHBhc3N3b3Jk +MAIL FROM: +RCPT TO: +DATA +Hey <3 +. +``` + +## Relationship with net/smtp + +The Go standard library provides a SMTP client implementation in `net/smtp`. +However `net/smtp` is frozen: it's not getting any new features. go-smtp +provides a server implementation and a number of client improvements. + +## Licence + +MIT diff --git a/vendor/github.com/emersion/go-smtp/backend.go b/vendor/github.com/emersion/go-smtp/backend.go new file mode 100644 index 0000000..d957f0f --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/backend.go @@ -0,0 +1,109 @@ +package smtp + +import ( + "io" +) + +var ( + ErrAuthRequired = &SMTPError{ + Code: 502, + EnhancedCode: EnhancedCode{5, 7, 0}, + Message: "Please authenticate first", + } + ErrAuthUnsupported = &SMTPError{ + Code: 502, + EnhancedCode: EnhancedCode{5, 7, 0}, + Message: "Authentication not supported", + } +) + +// A SMTP server backend. +type Backend interface { + // Authenticate a user. Return smtp.ErrAuthUnsupported if you don't want to + // support this. + Login(state *ConnectionState, username, password string) (Session, error) + + // Called if the client attempts to send mail without logging in first. + // Return smtp.ErrAuthRequired if you don't want to support this. + AnonymousLogin(state *ConnectionState) (Session, error) +} + +type BodyType string + +const ( + Body7Bit BodyType = "7BIT" + Body8BitMIME BodyType = "8BITMIME" + BodyBinaryMIME BodyType = "BINARYMIME" +) + +// MailOptions contains custom arguments that were +// passed as an argument to the MAIL command. +type MailOptions struct { + // Value of BODY= argument, 7BIT, 8BITMIME or BINARYMIME. + Body BodyType + + // Size of the body. Can be 0 if not specified by client. + Size int + + // TLS is required for the message transmission. + // + // The message should be rejected if it can't be transmitted + // with TLS. + RequireTLS bool + + // The message envelope or message header contains UTF-8-encoded strings. + // This flag is set by SMTPUTF8-aware (RFC 6531) client. + UTF8 bool + + // The authorization identity asserted by the message sender in decoded + // form with angle brackets stripped. + // + // nil value indicates missing AUTH, non-nil empty string indicates + // AUTH=<>. + // + // Defined in RFC 4954. + Auth *string +} + +// Session is used by servers to respond to an SMTP client. +// +// The methods are called when the remote client issues the matching command. +type Session interface { + // Discard currently processed message. + Reset() + + // Free all resources associated with session. + Logout() error + + // Set return path for currently processed message. + Mail(from string, opts *MailOptions) error + // Add recipient for currently processed message. + Rcpt(to string) error + // Set currently processed message contents and send it. + Data(r io.Reader) error +} + +// LMTPSession is an add-on interface for Session. It can be implemented by +// LMTP servers to provide extra functionality. +type LMTPSession interface { + // LMTPData is the LMTP-specific version of Data method. + // It can be optionally implemented by the backend to provide + // per-recipient status information when it is used over LMTP + // protocol. + // + // LMTPData implementation sets status information using passed + // StatusCollector by calling SetStatus once per each AddRcpt + // call, even if AddRcpt was called multiple times with + // the same argument. SetStatus must not be called after + // LMTPData returns. + // + // Return value of LMTPData itself is used as a status for + // recipients that got no status set before using StatusCollector. + LMTPData(r io.Reader, status StatusCollector) error +} + +// StatusCollector allows a backend to provide per-recipient status +// information. +type StatusCollector interface { + SetStatus(rcptTo string, err error) +} diff --git a/vendor/github.com/emersion/go-smtp/client.go b/vendor/github.com/emersion/go-smtp/client.go new file mode 100644 index 0000000..f1ed05f --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/client.go @@ -0,0 +1,722 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package smtp + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/textproto" + "strconv" + "strings" + "time" + + "github.com/emersion/go-sasl" +) + +// A Client represents a client connection to an SMTP server. +type Client struct { + // Text is the textproto.Conn used by the Client. It is exported to allow for + // clients to add extensions. + Text *textproto.Conn + + // keep a reference to the connection so it can be used to create a TLS + // connection later + conn net.Conn + // whether the Client is using TLS + tls bool + serverName string + lmtp bool + // map of supported extensions + ext map[string]string + // supported auth mechanisms + auth []string + localName string // the name to use in HELO/EHLO/LHLO + didHello bool // whether we've said HELO/EHLO/LHLO + helloError error // the error from the hello + rcpts []string // recipients accumulated for the current session + + // Time to wait for command responses (this includes 3xx reply to DATA). + CommandTimeout time.Duration + // Time to wait for responses after final dot. + SubmissionTimeout time.Duration + + // Logger for all network activity. + DebugWriter io.Writer +} + +// 30 seconds was chosen as it's the +// same duration as http.DefaultTransport's timeout. +var defaultTimeout = 30 * time.Second + +// Dial returns a new Client connected to an SMTP server at addr. +// The addr must include a port, as in "mail.example.com:smtp". +func Dial(addr string) (*Client, error) { + conn, err := net.DialTimeout("tcp", addr, defaultTimeout) + if err != nil { + return nil, err + } + host, _, _ := net.SplitHostPort(addr) + return NewClient(conn, host) +} + +// DialTLS returns a new Client connected to an SMTP server via TLS at addr. +// The addr must include a port, as in "mail.example.com:smtps". +// +// A nil tlsConfig is equivalent to a zero tls.Config. +func DialTLS(addr string, tlsConfig *tls.Config) (*Client, error) { + tlsDialer := tls.Dialer{ + NetDialer: &net.Dialer{ + Timeout: defaultTimeout, + }, + Config: tlsConfig, + } + conn, err := tlsDialer.Dial("tcp", addr) + if err != nil { + return nil, err + } + host, _, _ := net.SplitHostPort(addr) + return NewClient(conn, host) +} + +// NewClient returns a new Client using an existing connection and host as a +// server name to be used when authenticating. +func NewClient(conn net.Conn, host string) (*Client, error) { + c := &Client{ + serverName: host, + localName: "localhost", + // As recommended by RFC 5321. For DATA command reply (3xx one) RFC + // recommends a slightly shorter timeout but we do not bother + // differentiating these. + CommandTimeout: 5 * time.Minute, + // 10 minutes + 2 minute buffer in case the server is doing transparent + // forwarding and also follows recommended timeouts. + SubmissionTimeout: 12 * time.Minute, + } + + return c, c.InitConn(conn) +} + +// NewClientLMTP returns a new LMTP Client (as defined in RFC 2033) using an +// existing connector and host as a server name to be used when authenticating. +func NewClientLMTP(conn net.Conn, host string) (*Client, error) { + c, err := NewClient(conn, host) + if err != nil { + return nil, err + } + c.lmtp = true + return c, nil +} + +// setConn sets the underlying network connection for the client. +func (c *Client) setConn(conn net.Conn) { + c.conn = conn + + var r io.Reader = conn + var w io.Writer = conn + + r = &lineLimitReader{ + R: conn, + // Doubled maximum line length per RFC 5321 (Section 4.5.3.1.6) + LineLimit: 2000, + } + + r = io.TeeReader(r, clientDebugWriter{c}) + w = io.MultiWriter(w, clientDebugWriter{c}) + + rwc := struct { + io.Reader + io.Writer + io.Closer + }{ + Reader: r, + Writer: w, + Closer: conn, + } + c.Text = textproto.NewConn(rwc) + + _, isTLS := conn.(*tls.Conn) + c.tls = isTLS +} + +func (c *Client) InitConn(conn net.Conn) error { + if c.conn != nil { + return errors.New("smtp: client connection is already initialized") + } + + c.setConn(conn) + + // Set initial greeting timeout. + c.conn.SetDeadline(time.Now().Add(c.CommandTimeout)) + defer c.conn.SetDeadline(time.Time{}) + + _, _, err := c.Text.ReadResponse(220) + if err != nil { + c.Text.Close() + if protoErr, ok := err.(*textproto.Error); ok { + return toSMTPErr(protoErr) + } + return err + } + + return nil +} + +// Close closes the connection. +func (c *Client) Close() error { + return c.Text.Close() +} + +// hello runs a hello exchange if needed. +func (c *Client) hello() error { + if !c.didHello { + c.didHello = true + err := c.ehlo() + if err != nil { + c.helloError = c.helo() + } + } + return c.helloError +} + +// Hello sends a HELO or EHLO to the server as the given host name. +// Calling this method is only necessary if the client needs control +// over the host name used. The client will introduce itself as "localhost" +// automatically otherwise. If Hello is called, it must be called before +// any of the other methods. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) Hello(localName string) error { + if err := validateLine(localName); err != nil { + return err + } + if c.didHello { + return errors.New("smtp: Hello called after other methods") + } + c.localName = localName + return c.hello() +} + +// cmd is a convenience function that sends a command and returns the response +// textproto.Error returned by c.Text.ReadResponse is converted into SMTPError. +func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { + c.conn.SetDeadline(time.Now().Add(c.CommandTimeout)) + defer c.conn.SetDeadline(time.Time{}) + + id, err := c.Text.Cmd(format, args...) + if err != nil { + return 0, "", err + } + c.Text.StartResponse(id) + defer c.Text.EndResponse(id) + code, msg, err := c.Text.ReadResponse(expectCode) + if err != nil { + if protoErr, ok := err.(*textproto.Error); ok { + smtpErr := toSMTPErr(protoErr) + return code, smtpErr.Message, smtpErr + } + return code, msg, err + } + return code, msg, nil +} + +// helo sends the HELO greeting to the server. It should be used only when the +// server does not support ehlo. +func (c *Client) helo() error { + c.ext = nil + _, _, err := c.cmd(250, "HELO %s", c.localName) + return err +} + +// ehlo sends the EHLO (extended hello) greeting to the server. It +// should be the preferred greeting for servers that support it. +func (c *Client) ehlo() error { + cmd := "EHLO" + if c.lmtp { + cmd = "LHLO" + } + + _, msg, err := c.cmd(250, "%s %s", cmd, c.localName) + if err != nil { + return err + } + ext := make(map[string]string) + extList := strings.Split(msg, "\n") + if len(extList) > 1 { + extList = extList[1:] + for _, line := range extList { + args := strings.SplitN(line, " ", 2) + if len(args) > 1 { + ext[args[0]] = args[1] + } else { + ext[args[0]] = "" + } + } + } + if mechs, ok := ext["AUTH"]; ok { + c.auth = strings.Split(mechs, " ") + } + c.ext = ext + return err +} + +// StartTLS sends the STARTTLS command and encrypts all further communication. +// Only servers that advertise the STARTTLS extension support this function. +// +// A nil config is equivalent to a zero tls.Config. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) StartTLS(config *tls.Config) error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(220, "STARTTLS") + if err != nil { + return err + } + if config == nil { + config = &tls.Config{} + } + if config.ServerName == "" { + // Make a copy to avoid polluting argument + config = config.Clone() + config.ServerName = c.serverName + } + if testHookStartTLS != nil { + testHookStartTLS(config) + } + c.setConn(tls.Client(c.conn, config)) + return c.ehlo() +} + +// TLSConnectionState returns the client's TLS connection state. +// The return values are their zero values if StartTLS did +// not succeed. +func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { + tc, ok := c.conn.(*tls.Conn) + if !ok { + return + } + return tc.ConnectionState(), true +} + +// Verify checks the validity of an email address on the server. +// If Verify returns nil, the address is valid. A non-nil return +// does not necessarily indicate an invalid address. Many servers +// will not verify addresses for security reasons. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) Verify(addr string) error { + if err := validateLine(addr); err != nil { + return err + } + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(250, "VRFY %s", addr) + return err +} + +// Auth authenticates a client using the provided authentication mechanism. +// Only servers that advertise the AUTH extension support this function. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) Auth(a sasl.Client) error { + if err := c.hello(); err != nil { + return err + } + encoding := base64.StdEncoding + mech, resp, err := a.Start() + if err != nil { + return err + } + resp64 := make([]byte, encoding.EncodedLen(len(resp))) + encoding.Encode(resp64, resp) + code, msg64, err := c.cmd(0, strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64))) + for err == nil { + var msg []byte + switch code { + case 334: + msg, err = encoding.DecodeString(msg64) + case 235: + // the last message isn't base64 because it isn't a challenge + msg = []byte(msg64) + default: + err = toSMTPErr(&textproto.Error{Code: code, Msg: msg64}) + } + if err == nil { + if code == 334 { + resp, err = a.Next(msg) + } else { + resp = nil + } + } + if err != nil { + // abort the AUTH + c.cmd(501, "*") + break + } + if resp == nil { + break + } + resp64 = make([]byte, encoding.EncodedLen(len(resp))) + encoding.Encode(resp64, resp) + code, msg64, err = c.cmd(0, string(resp64)) + } + return err +} + +// Mail issues a MAIL command to the server using the provided email address. +// If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME +// parameter. +// This initiates a mail transaction and is followed by one or more Rcpt calls. +// +// If opts is not nil, MAIL arguments provided in the structure will be added +// to the command. Handling of unsupported options depends on the extension. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) Mail(from string, opts *MailOptions) error { + if err := validateLine(from); err != nil { + return err + } + if err := c.hello(); err != nil { + return err + } + cmdStr := "MAIL FROM:<%s>" + if _, ok := c.ext["8BITMIME"]; ok { + cmdStr += " BODY=8BITMIME" + } + if _, ok := c.ext["SIZE"]; ok && opts != nil && opts.Size != 0 { + cmdStr += " SIZE=" + strconv.Itoa(opts.Size) + } + if opts != nil && opts.RequireTLS { + if _, ok := c.ext["REQUIRETLS"]; ok { + cmdStr += " REQUIRETLS" + } else { + return errors.New("smtp: server does not support REQUIRETLS") + } + } + if opts != nil && opts.UTF8 { + if _, ok := c.ext["SMTPUTF8"]; ok { + cmdStr += " SMTPUTF8" + } else { + return errors.New("smtp: server does not support SMTPUTF8") + } + } + if opts != nil && opts.Auth != nil { + if _, ok := c.ext["AUTH"]; ok { + cmdStr += " AUTH=" + encodeXtext(*opts.Auth) + } + // We can safely discard parameter if server does not support AUTH. + } + _, _, err := c.cmd(250, cmdStr, from) + return err +} + +// Rcpt issues a RCPT command to the server using the provided email address. +// A call to Rcpt must be preceded by a call to Mail and may be followed by +// a Data call or another Rcpt call. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) Rcpt(to string) error { + if err := validateLine(to); err != nil { + return err + } + if _, _, err := c.cmd(25, "RCPT TO:<%s>", to); err != nil { + return err + } + c.rcpts = append(c.rcpts, to) + return nil +} + +type dataCloser struct { + c *Client + io.WriteCloser + statusCb func(rcpt string, status *SMTPError) +} + +func (d *dataCloser) Close() error { + d.WriteCloser.Close() + + d.c.conn.SetDeadline(time.Now().Add(d.c.SubmissionTimeout)) + defer d.c.conn.SetDeadline(time.Time{}) + + expectedResponses := len(d.c.rcpts) + if d.c.lmtp { + for expectedResponses > 0 { + rcpt := d.c.rcpts[len(d.c.rcpts)-expectedResponses] + if _, _, err := d.c.Text.ReadResponse(250); err != nil { + if protoErr, ok := err.(*textproto.Error); ok { + if d.statusCb != nil { + d.statusCb(rcpt, toSMTPErr(protoErr)) + } + } else { + return err + } + } else if d.statusCb != nil { + d.statusCb(rcpt, nil) + } + expectedResponses-- + } + return nil + } else { + _, _, err := d.c.Text.ReadResponse(250) + if err != nil { + if protoErr, ok := err.(*textproto.Error); ok { + return toSMTPErr(protoErr) + } + return err + } + return nil + } +} + +// Data issues a DATA command to the server and returns a writer that +// can be used to write the mail headers and body. The caller should +// close the writer before calling any more methods on c. A call to +// Data must be preceded by one or more calls to Rcpt. +// +// If server returns an error, it will be of type *SMTPError. +func (c *Client) Data() (io.WriteCloser, error) { + _, _, err := c.cmd(354, "DATA") + if err != nil { + return nil, err + } + return &dataCloser{c, c.Text.DotWriter(), nil}, nil +} + +// LMTPData is the LMTP-specific version of the Data method. It accepts a callback +// that will be called for each status response received from the server. +// +// Status callback will receive a SMTPError argument for each negative server +// reply and nil for each positive reply. I/O errors will not be reported using +// callback and instead will be returned by the Close method of io.WriteCloser. +// Callback will be called for each successfull Rcpt call done before in the +// same order. +func (c *Client) LMTPData(statusCb func(rcpt string, status *SMTPError)) (io.WriteCloser, error) { + if !c.lmtp { + return nil, errors.New("smtp: not a LMTP client") + } + + _, _, err := c.cmd(354, "DATA") + if err != nil { + return nil, err + } + return &dataCloser{c, c.Text.DotWriter(), statusCb}, nil +} + +// SendMail will use an existing connection to send an email from +// address from, to addresses to, with message r. +// +// This function does not start TLS, nor does it perform authentication. Use +// StartTLS and Auth before-hand if desirable. +// +// The addresses in the to parameter are the SMTP RCPT addresses. +// +// The r parameter should be an RFC 822-style email with headers +// first, a blank line, and then the message body. The lines of r +// should be CRLF terminated. The r headers should usually include +// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc" +// messages is accomplished by including an email address in the to +// parameter but not including it in the r headers. +func (c *Client) SendMail(from string, to []string, r io.Reader) error { + var err error + + if err = c.Mail(from, nil); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = io.Copy(w, r) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} + +var testHookStartTLS func(*tls.Config) // nil, except for tests + +// SendMail connects to the server at addr, switches to TLS, authenticates with +// the optional SASL client, and then sends an email from address from, to +// addresses to, with message r. The addr must include a port, as in +// "mail.example.com:smtp". +// +// The addresses in the to parameter are the SMTP RCPT addresses. +// +// The r parameter should be an RFC 822-style email with headers +// first, a blank line, and then the message body. The lines of r +// should be CRLF terminated. The r headers should usually include +// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc" +// messages is accomplished by including an email address in the to +// parameter but not including it in the r headers. +// +// SendMail is intended to be used for very simple use-cases. If you want to +// customize SendMail's behavior, use a Client instead. +// +// The SendMail function and the go-smtp package are low-level +// mechanisms and provide no support for DKIM signing (see go-msgauth), MIME +// attachments (see the mime/multipart package or the go-message package), or +// other mail functionality. +func SendMail(addr string, a sasl.Client, from string, to []string, r io.Reader) error { + if err := validateLine(from); err != nil { + return err + } + for _, recp := range to { + if err := validateLine(recp); err != nil { + return err + } + } + c, err := Dial(addr) + if err != nil { + return err + } + defer c.Close() + + if err = c.hello(); err != nil { + return err + } + if ok, _ := c.Extension("STARTTLS"); !ok { + return errors.New("smtp: server doesn't support STARTTLS") + } + if err = c.StartTLS(nil); err != nil { + return err + } + if a != nil && c.ext != nil { + if _, ok := c.ext["AUTH"]; !ok { + return errors.New("smtp: server doesn't support AUTH") + } + if err = c.Auth(a); err != nil { + return err + } + } + return c.SendMail(from, to, r) +} + +// Extension reports whether an extension is support by the server. +// The extension name is case-insensitive. If the extension is supported, +// Extension also returns a string that contains any parameters the +// server specifies for the extension. +func (c *Client) Extension(ext string) (bool, string) { + if err := c.hello(); err != nil { + return false, "" + } + if c.ext == nil { + return false, "" + } + ext = strings.ToUpper(ext) + param, ok := c.ext[ext] + return ok, param +} + +// Reset sends the RSET command to the server, aborting the current mail +// transaction. +func (c *Client) Reset() error { + if err := c.hello(); err != nil { + return err + } + if _, _, err := c.cmd(250, "RSET"); err != nil { + return err + } + c.rcpts = nil + return nil +} + +// Noop sends the NOOP command to the server. It does nothing but check +// that the connection to the server is okay. +func (c *Client) Noop() error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(250, "NOOP") + return err +} + +// Quit sends the QUIT command and closes the connection to the server. +// +// If Quit fails the connection is not closed, Close should be used +// in this case. +func (c *Client) Quit() error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(221, "QUIT") + if err != nil { + return err + } + return c.Text.Close() +} + +func parseEnhancedCode(s string) (EnhancedCode, error) { + parts := strings.Split(s, ".") + if len(parts) != 3 { + return EnhancedCode{}, fmt.Errorf("wrong amount of enhanced code parts") + } + + code := EnhancedCode{} + for i, part := range parts { + num, err := strconv.Atoi(part) + if err != nil { + return code, err + } + code[i] = num + } + return code, nil +} + +// toSMTPErr converts textproto.Error into SMTPError, parsing +// enhanced status code if it is present. +func toSMTPErr(protoErr *textproto.Error) *SMTPError { + if protoErr == nil { + return nil + } + smtpErr := &SMTPError{ + Code: protoErr.Code, + Message: protoErr.Msg, + } + + parts := strings.SplitN(protoErr.Msg, " ", 2) + if len(parts) != 2 { + return smtpErr + } + + enchCode, err := parseEnhancedCode(parts[0]) + if err != nil { + return smtpErr + } + + msg := parts[1] + + // Per RFC 2034, enhanced code should be prepended to each line. + msg = strings.ReplaceAll(msg, "\n"+parts[0]+" ", "\n") + + smtpErr.EnhancedCode = enchCode + smtpErr.Message = msg + return smtpErr +} + +type clientDebugWriter struct { + c *Client +} + +func (cdw clientDebugWriter) Write(b []byte) (int, error) { + if cdw.c.DebugWriter == nil { + return len(b), nil + } + return cdw.c.DebugWriter.Write(b) +} diff --git a/vendor/github.com/emersion/go-smtp/conn.go b/vendor/github.com/emersion/go-smtp/conn.go new file mode 100644 index 0000000..ad58b9d --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/conn.go @@ -0,0 +1,1019 @@ +package smtp + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "net/textproto" + "regexp" + "runtime/debug" + "strconv" + "strings" + "sync" + "time" +) + +// Number of errors we'll tolerate per connection before closing. Defaults to 3. +const errThreshold = 3 + +type ConnectionState struct { + ServerDomain string + Hostname string + LocalAddr net.Addr + RemoteAddr net.Addr + TLS tls.ConnectionState +} + +type Conn struct { + conn net.Conn + text *textproto.Conn + server *Server + helo string + + // Number of errors witnessed on this connection + errCount int + + session Session + locker sync.Mutex + binarymime bool + + lineLimitReader *lineLimitReader + bdatPipe *io.PipeWriter + bdatStatus *statusCollector // used for BDAT on LMTP + dataResult chan error + bytesReceived int // counts total size of chunks when BDAT is used + + fromReceived bool + recipients []string + didAuth bool +} + +func newConn(c net.Conn, s *Server) *Conn { + sc := &Conn{ + server: s, + conn: c, + } + + sc.init() + return sc +} + +func (c *Conn) init() { + c.lineLimitReader = &lineLimitReader{ + R: c.conn, + LineLimit: c.server.MaxLineLength, + } + rwc := struct { + io.Reader + io.Writer + io.Closer + }{ + Reader: c.lineLimitReader, + Writer: c.conn, + Closer: c.conn, + } + + if c.server.Debug != nil { + rwc = struct { + io.Reader + io.Writer + io.Closer + }{ + io.TeeReader(rwc.Reader, c.server.Debug), + io.MultiWriter(rwc.Writer, c.server.Debug), + rwc.Closer, + } + } + + c.text = textproto.NewConn(rwc) +} + +// Commands are dispatched to the appropriate handler functions. +func (c *Conn) handle(cmd string, arg string) { + // If panic happens during command handling - send 421 response + // and close connection. + defer func() { + if err := recover(); err != nil { + c.WriteResponse(421, EnhancedCode{4, 0, 0}, "Internal server error") + c.Close() + + stack := debug.Stack() + c.server.ErrorLog.Printf(c, "panic serving %v: %w\n%s", c.State().RemoteAddr, err, stack) + } + }() + + if cmd == "" { + msg := "Error: bad syntax" + c.server.ErrorLog.Printf(c, "%s", msg) + c.protocolError(500, EnhancedCode{5, 5, 2}, msg) + return + } + + cmd = strings.ToUpper(cmd) + switch cmd { + case "SEND", "SOML", "SAML", "EXPN", "HELP", "TURN": + // These commands are not implemented in any state + c.WriteResponse(502, EnhancedCode{5, 5, 1}, fmt.Sprintf("%v command not implemented", cmd)) + case "HELO", "EHLO", "LHLO": + lmtp := cmd == "LHLO" + enhanced := lmtp || cmd == "EHLO" + if c.server.LMTP && !lmtp { + c.WriteResponse(500, EnhancedCode{5, 5, 1}, "This is a LMTP server, use LHLO") + return + } + if !c.server.LMTP && lmtp { + c.WriteResponse(500, EnhancedCode{5, 5, 1}, "This is not a LMTP server") + return + } + c.handleGreet(enhanced, arg) + case "MAIL": + c.handleMail(arg) + case "RCPT": + c.handleRcpt(arg) + case "VRFY": + c.WriteResponse(252, EnhancedCode{2, 5, 0}, "Cannot VRFY user, but will accept message") + case "NOOP": + c.WriteResponse(250, EnhancedCode{2, 0, 0}, "I have sucessfully done nothing") + case "RSET": // Reset session + c.reset() + c.WriteResponse(250, EnhancedCode{2, 0, 0}, "Session reset") + case "BDAT": + c.handleBdat(arg) + case "DATA": + c.handleData(arg) + case "QUIT": + c.WriteResponse(221, EnhancedCode{2, 0, 0}, "Bye") + c.Close() + case "AUTH": + if c.server.AuthDisabled { + msg := "Syntax error, AUTH command unrecognized" + c.server.ErrorLog.Printf(c, "%s", msg) + c.protocolError(500, EnhancedCode{5, 5, 2}, msg) + } else { + c.handleAuth(arg) + } + case "STARTTLS": + c.handleStartTLS() + default: + msg := fmt.Sprintf("Syntax errors, %v command unrecognized", cmd) + c.server.ErrorLog.Printf(c, "%s", msg) + c.protocolError(500, EnhancedCode{5, 5, 2}, msg) + } +} + +func (c *Conn) Server() *Server { + return c.server +} + +func (c *Conn) Session() Session { + c.locker.Lock() + defer c.locker.Unlock() + return c.session +} + +// Setting the user resets any message being generated +func (c *Conn) SetSession(session Session) { + c.locker.Lock() + defer c.locker.Unlock() + c.session = session +} + +func (c *Conn) Close() error { + c.locker.Lock() + defer c.locker.Unlock() + + if c.bdatPipe != nil { + c.bdatPipe.CloseWithError(ErrDataReset) + c.bdatPipe = nil + } + + if c.session != nil { + c.session.Logout() + c.session = nil + } + + return c.conn.Close() +} + +// TLSConnectionState returns the connection's TLS connection state. +// Zero values are returned if the connection doesn't use TLS. +func (c *Conn) TLSConnectionState() (state tls.ConnectionState, ok bool) { + tc, ok := c.conn.(*tls.Conn) + if !ok { + return + } + return tc.ConnectionState(), true +} + +func (c *Conn) State() ConnectionState { + state := ConnectionState{} + tlsState, ok := c.TLSConnectionState() + if ok { + state.TLS = tlsState + } + + state.ServerDomain = c.server.Domain + state.Hostname = c.helo + state.LocalAddr = c.conn.LocalAddr() + state.RemoteAddr = c.conn.RemoteAddr() + + return state +} + +func (c *Conn) authAllowed() bool { + _, isTLS := c.TLSConnectionState() + return !c.server.AuthDisabled && (isTLS || c.server.AllowInsecureAuth) +} + +// protocolError writes errors responses and closes the connection once too many +// have occurred. +func (c *Conn) protocolError(code int, ec EnhancedCode, msg string) { + c.WriteResponse(code, ec, msg) + + c.errCount++ + if c.errCount > errThreshold { + c.WriteResponse(500, EnhancedCode{5, 5, 1}, "Too many errors. Quiting now") + c.Close() + } +} + +// GREET state -> waiting for HELO +func (c *Conn) handleGreet(enhanced bool, arg string) { + if !enhanced { + domain, err := parseHelloArgument(arg) + if err != nil { + c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Domain/address argument required for HELO") + return + } + c.helo = domain + + c.WriteResponse(250, EnhancedCode{2, 0, 0}, fmt.Sprintf("Hello %s", domain)) + } else { + domain, err := parseHelloArgument(arg) + if err != nil { + c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Domain/address argument required for EHLO") + return + } + + c.helo = domain + + caps := []string{} + caps = append(caps, c.server.caps...) + if _, isTLS := c.TLSConnectionState(); c.server.TLSConfig != nil && !isTLS { + caps = append(caps, "STARTTLS") + } + if c.authAllowed() { + authCap := "AUTH" + for name := range c.server.auths { + authCap += " " + name + } + + caps = append(caps, authCap) + } + if c.server.EnableSMTPUTF8 { + caps = append(caps, "SMTPUTF8") + } + if _, isTLS := c.TLSConnectionState(); isTLS && c.server.EnableREQUIRETLS { + caps = append(caps, "REQUIRETLS") + } + if c.server.EnableBINARYMIME { + caps = append(caps, "BINARYMIME") + } + if c.server.MaxMessageBytes > 0 { + caps = append(caps, fmt.Sprintf("SIZE %v", c.server.MaxMessageBytes)) + } else { + caps = append(caps, "SIZE") + } + + args := []string{"Hello " + domain} + args = append(args, caps...) + c.WriteResponse(250, NoEnhancedCode, args...) + } +} + +// READY state -> waiting for MAIL +func (c *Conn) handleMail(arg string) { + if c.helo == "" { + c.WriteResponse(502, EnhancedCode{2, 5, 1}, "Please introduce yourself first.") + return + } + if c.bdatPipe != nil { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "MAIL not allowed during message transfer") + return + } + + if c.Session() == nil { + state := c.State() + session, err := c.server.Backend.AnonymousLogin(&state) + if err != nil { + if smtpErr, ok := err.(*SMTPError); ok { + c.WriteResponse(smtpErr.Code, smtpErr.EnhancedCode, smtpErr.Message) + } else { + c.WriteResponse(502, EnhancedCode{5, 7, 0}, err.Error()) + } + return + } + + c.SetSession(session) + c.didAuth = true + } + + if len(arg) < 6 || strings.ToUpper(arg[0:5]) != "FROM:" { + c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Was expecting MAIL arg syntax of FROM:
") + return + } + fromArgs := strings.Split(strings.Trim(arg[5:], " "), " ") + if c.server.Strict { + if !strings.HasPrefix(fromArgs[0], "<") || !strings.HasSuffix(fromArgs[0], ">") { + c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Was expecting MAIL arg syntax of FROM:
") + return + } + } + from := fromArgs[0] + if from == "" { + c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Was expecting MAIL arg syntax of FROM:
") + return + } + from = strings.Trim(from, "<>") + + opts := &MailOptions{} + + c.binarymime = false + // This is where the Conn may put BODY=8BITMIME, but we already + // read the DATA as bytes, so it does not effect our processing. + if len(fromArgs) > 1 { + args, err := parseArgs(fromArgs[1:]) + if err != nil { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "Unable to parse MAIL ESMTP parameters") + return + } + + for key, value := range args { + switch key { + case "SIZE": + size, err := strconv.ParseInt(value, 10, 32) + if err != nil { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "Unable to parse SIZE as an integer") + return + } + + if c.server.MaxMessageBytes > 0 && int(size) > c.server.MaxMessageBytes { + c.WriteResponse(552, EnhancedCode{5, 3, 4}, "Max message size exceeded") + return + } + + opts.Size = int(size) + case "SMTPUTF8": + if !c.server.EnableSMTPUTF8 { + c.WriteResponse(504, EnhancedCode{5, 5, 4}, "SMTPUTF8 is not implemented") + return + } + opts.UTF8 = true + case "REQUIRETLS": + if !c.server.EnableREQUIRETLS { + c.WriteResponse(504, EnhancedCode{5, 5, 4}, "REQUIRETLS is not implemented") + return + } + opts.RequireTLS = true + case "BODY": + switch value { + case "BINARYMIME": + if !c.server.EnableBINARYMIME { + c.WriteResponse(504, EnhancedCode{5, 5, 4}, "BINARYMIME is not implemented") + return + } + c.binarymime = true + case "7BIT", "8BITMIME": + default: + c.WriteResponse(500, EnhancedCode{5, 5, 4}, "Unknown BODY value") + return + } + opts.Body = BodyType(value) + case "AUTH": + value, err := decodeXtext(value) + if err != nil { + c.WriteResponse(500, EnhancedCode{5, 5, 4}, "Malformed AUTH parameter value") + return + } + if !strings.HasPrefix(value, "<") { + c.WriteResponse(500, EnhancedCode{5, 5, 4}, "Missing opening angle bracket") + return + } + if !strings.HasSuffix(value, ">") { + c.WriteResponse(500, EnhancedCode{5, 5, 4}, "Missing closing angle bracket") + return + } + decodedMbox := value[1 : len(value)-1] + opts.Auth = &decodedMbox + default: + c.WriteResponse(500, EnhancedCode{5, 5, 4}, "Unknown MAIL FROM argument") + return + } + } + } + + if err := c.Session().Mail(from, opts); err != nil { + if smtpErr, ok := err.(*SMTPError); ok { + c.WriteResponse(smtpErr.Code, smtpErr.EnhancedCode, smtpErr.Message) + return + } + c.WriteResponse(451, EnhancedCode{4, 0, 0}, err.Error()) + return + } + + c.WriteResponse(250, EnhancedCode{2, 0, 0}, fmt.Sprintf("Roger, accepting mail from <%v>", from)) + c.fromReceived = true +} + +// This regexp matches 'hexchar' token defined in +// https://tools.ietf.org/html/rfc4954#section-8 however it is intentionally +// relaxed by requiring only '+' to be present. It allows us to detect +// malformed values such as +A or +HH and report them appropriately. +var hexcharRe = regexp.MustCompile(`\+[0-9A-F]?[0-9A-F]?`) + +func decodeXtext(val string) (string, error) { + if !strings.Contains(val, "+") { + return val, nil + } + + var replaceErr error + decoded := hexcharRe.ReplaceAllStringFunc(val, func(match string) string { + if len(match) != 3 { + replaceErr = errors.New("incomplete hexchar") + return "" + } + char, err := strconv.ParseInt(match, 16, 8) + if err != nil { + replaceErr = err + return "" + } + + return string(rune(char)) + }) + if replaceErr != nil { + return "", replaceErr + } + + return decoded, nil +} + +func encodeXtext(raw string) string { + var out strings.Builder + out.Grow(len(raw)) + + for _, ch := range raw { + if ch == '+' || ch == '=' { + out.WriteRune('+') + out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16))) + } + if ch > '!' && ch < '~' { // printable non-space US-ASCII + out.WriteRune(ch) + } + // Non-ASCII. + out.WriteRune('+') + out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16))) + } + return out.String() +} + +// MAIL state -> waiting for RCPTs followed by DATA +func (c *Conn) handleRcpt(arg string) { + if !c.fromReceived { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "Missing MAIL FROM command.") + return + } + if c.bdatPipe != nil { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "RCPT not allowed during message transfer") + return + } + + if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") { + c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Was expecting RCPT arg syntax of TO:
") + return + } + + // TODO: This trim is probably too forgiving + recipient := strings.Trim(arg[3:], "<> ") + + if c.server.MaxRecipients > 0 && len(c.recipients) >= c.server.MaxRecipients { + c.WriteResponse(552, EnhancedCode{5, 5, 3}, fmt.Sprintf("Maximum limit of %v recipients reached", c.server.MaxRecipients)) + return + } + + if err := c.Session().Rcpt(recipient); err != nil { + if smtpErr, ok := err.(*SMTPError); ok { + c.WriteResponse(smtpErr.Code, smtpErr.EnhancedCode, smtpErr.Message) + return + } + c.WriteResponse(451, EnhancedCode{4, 0, 0}, err.Error()) + return + } + c.recipients = append(c.recipients, recipient) + c.WriteResponse(250, EnhancedCode{2, 0, 0}, fmt.Sprintf("I'll make sure <%v> gets this", recipient)) +} + +func (c *Conn) handleAuth(arg string) { + if c.helo == "" { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "Please introduce yourself first.") + return + } + if c.didAuth { + c.WriteResponse(503, EnhancedCode{5, 5, 1}, "Already authenticated") + return + } + + parts := strings.Fields(arg) + if len(parts) == 0 { + c.WriteResponse(502, EnhancedCode{5, 5, 4}, "Missing parameter") + return + } + + if _, isTLS := c.TLSConnectionState(); !isTLS && !c.server.AllowInsecureAuth { + c.WriteResponse(523, EnhancedCode{5, 7, 10}, "TLS is required") + return + } + + mechanism := strings.ToUpper(parts[0]) + + // Parse client initial response if there is one + var ir []byte + if len(parts) > 1 { + var err error + ir, err = base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return + } + } + + newSasl, ok := c.server.auths[mechanism] + if !ok { + c.WriteResponse(504, EnhancedCode{5, 7, 4}, "Unsupported authentication mechanism") + return + } + + sasl := newSasl(c) + + response := ir + for { + challenge, done, err := sasl.Next(response) + if err != nil { + if smtpErr, ok := err.(*SMTPError); ok { + c.WriteResponse(smtpErr.Code, smtpErr.EnhancedCode, smtpErr.Message) + return + } + c.WriteResponse(454, EnhancedCode{4, 7, 0}, err.Error()) + return + } + + if done { + break + } + + encoded := "" + if len(challenge) > 0 { + encoded = base64.StdEncoding.EncodeToString(challenge) + } + c.WriteResponse(334, NoEnhancedCode, encoded) + + encoded, err = c.ReadLine() + if err != nil { + return // TODO: error handling + } + + if encoded == "*" { + // https://tools.ietf.org/html/rfc4954#page-4 + c.WriteResponse(501, EnhancedCode{5, 0, 0}, "Negotiation cancelled") + return + } + + response, err = base64.StdEncoding.DecodeString(encoded) + if err != nil { + c.WriteResponse(454, EnhancedCode{4, 7, 0}, "Invalid base64 data") + return + } + } + + c.WriteResponse(235, EnhancedCode{2, 0, 0}, "Authentication succeeded") + c.didAuth = true +} + +func (c *Conn) handleStartTLS() { + if _, isTLS := c.TLSConnectionState(); isTLS { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "Already running in TLS") + return + } + + if c.server.TLSConfig == nil { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "TLS not supported") + return + } + + c.WriteResponse(220, EnhancedCode{2, 0, 0}, "Ready to start TLS") + + // Upgrade to TLS + tlsConn := tls.Server(c.conn, c.server.TLSConfig) + + if err := tlsConn.Handshake(); err != nil { + c.server.ErrorLog.Printf(c, "TLS handshake error for %s: %w", c.State().RemoteAddr, err) + c.WriteResponse(550, EnhancedCode{5, 0, 0}, "Handshake error") + return + } + + c.conn = tlsConn + c.init() + + // Reset all state and close the previous Session. + // This is different from just calling reset() since we want the Backend to + // be able to see the information about TLS connection in the + // ConnectionState object passed to it. + if session := c.Session(); session != nil { + session.Logout() + c.SetSession(nil) + } + c.didAuth = false + c.reset() +} + +// DATA +func (c *Conn) handleData(arg string) { + if arg != "" { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "DATA command should not have any arguments") + return + } + if c.bdatPipe != nil { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "DATA not allowed during message transfer") + return + } + if c.binarymime { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "DATA not allowed for BINARYMIME messages") + return + } + + if !c.fromReceived || len(c.recipients) == 0 { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "Missing RCPT TO command.") + return + } + + // We have recipients, go to accept data + c.WriteResponse(354, EnhancedCode{2, 0, 0}, "Go ahead. End your data with .") + + defer c.reset() + + if c.server.LMTP { + c.handleDataLMTP() + return + } + + r := newDataReader(c) + code, enhancedCode, msg := toSMTPStatus(c.Session().Data(r)) + r.limited = false + io.Copy(ioutil.Discard, r) // Make sure all the data has been consumed + c.WriteResponse(code, enhancedCode, msg) +} + +func (c *Conn) handleBdat(arg string) { + args := strings.Fields(arg) + if len(args) == 0 { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "Missing chunk size argument") + return + } + if len(args) > 2 { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "Too many arguments") + return + } + + if !c.fromReceived || len(c.recipients) == 0 { + c.WriteResponse(502, EnhancedCode{5, 5, 1}, "Missing RCPT TO command.") + return + } + + last := false + if len(args) == 2 { + if !strings.EqualFold(args[1], "LAST") { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "Unknown BDAT argument") + return + } + last = true + } + + // ParseUint instead of Atoi so we will not accept negative values. + size, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { + c.WriteResponse(501, EnhancedCode{5, 5, 4}, "Malformed size argument") + return + } + + if c.server.MaxMessageBytes != 0 && c.bytesReceived+int(size) > c.server.MaxMessageBytes { + c.WriteResponse(552, EnhancedCode{5, 3, 4}, "Max message size exceeded") + + // Discard chunk itself without passing it to backend. + io.Copy(ioutil.Discard, io.LimitReader(c.text.R, int64(size))) + + c.reset() + return + } + + if c.bdatStatus == nil && c.server.LMTP { + c.bdatStatus = c.createStatusCollector() + } + + if c.bdatPipe == nil { + var r *io.PipeReader + r, c.bdatPipe = io.Pipe() + + c.dataResult = make(chan error, 1) + + go func() { + defer func() { + if err := recover(); err != nil { + c.handlePanic(err, c.bdatStatus) + + c.dataResult <- errPanic + r.CloseWithError(errPanic) + } + }() + + var err error + if !c.server.LMTP { + err = c.Session().Data(r) + } else { + lmtpSession, ok := c.Session().(LMTPSession) + if !ok { + err = c.Session().Data(r) + for _, rcpt := range c.recipients { + c.bdatStatus.SetStatus(rcpt, err) + } + } else { + err = lmtpSession.LMTPData(r, c.bdatStatus) + } + } + + c.dataResult <- err + r.CloseWithError(err) + }() + } + + c.lineLimitReader.LineLimit = 0 + + chunk := io.LimitReader(c.text.R, int64(size)) + _, err = io.Copy(c.bdatPipe, chunk) + if err != nil { + // Backend might return an error early using CloseWithError without consuming + // the whole chunk. + io.Copy(ioutil.Discard, chunk) + + c.WriteResponse(toSMTPStatus(err)) + + if err == errPanic { + c.Close() + } + + c.reset() + c.lineLimitReader.LineLimit = c.server.MaxLineLength + return + } + + c.bytesReceived += int(size) + + if last { + c.lineLimitReader.LineLimit = c.server.MaxLineLength + + c.bdatPipe.Close() + + err := <-c.dataResult + + if c.server.LMTP { + c.bdatStatus.fillRemaining(err) + for i, rcpt := range c.recipients { + code, enchCode, msg := toSMTPStatus(<-c.bdatStatus.status[i]) + c.WriteResponse(code, enchCode, "<"+rcpt+"> "+msg) + } + } else { + c.WriteResponse(toSMTPStatus(err)) + } + + if err == errPanic { + c.Close() + return + } + + c.reset() + } else { + c.WriteResponse(250, EnhancedCode{2, 0, 0}, "Continue") + } +} + +// ErrDataReset is returned by Reader pased to Data function if client does not +// send another BDAT command and instead closes connection or issues RSET command. +var ErrDataReset = errors.New("smtp: message transmission aborted") + +var errPanic = &SMTPError{ + Code: 421, + EnhancedCode: EnhancedCode{4, 0, 0}, + Message: "Internal server error", +} + +func (c *Conn) handlePanic(err interface{}, status *statusCollector) { + if status != nil { + status.fillRemaining(errPanic) + } + + stack := debug.Stack() + c.server.ErrorLog.Printf(c, "panic serving %v: %w\n%s", c.State().RemoteAddr, err, stack) +} + +func (c *Conn) createStatusCollector() *statusCollector { + rcptCounts := make(map[string]int, len(c.recipients)) + + status := &statusCollector{ + statusMap: make(map[string]chan error, len(c.recipients)), + status: make([]chan error, 0, len(c.recipients)), + } + for _, rcpt := range c.recipients { + rcptCounts[rcpt]++ + } + // Create channels with buffer sizes necessary to fit all + // statuses for a single recipient to avoid deadlocks. + for rcpt, count := range rcptCounts { + status.statusMap[rcpt] = make(chan error, count) + } + for _, rcpt := range c.recipients { + status.status = append(status.status, status.statusMap[rcpt]) + } + + return status +} + +type statusCollector struct { + // Contains map from recipient to list of channels that are used for that + // recipient. + statusMap map[string]chan error + + // Contains channels from statusMap, in the same + // order as Conn.recipients. + status []chan error +} + +// fillRemaining sets status for all recipients SetStatus was not called for before. +func (s *statusCollector) fillRemaining(err error) { + // Amount of times certain recipient was specified is indicated by the channel + // buffer size, so once we fill it, we can be confident that we sent + // at least as much statuses as needed. Extra statuses will be ignored anyway. +chLoop: + for _, ch := range s.statusMap { + for { + select { + case ch <- err: + default: + continue chLoop + } + } + } +} + +func (s *statusCollector) SetStatus(rcptTo string, err error) { + ch := s.statusMap[rcptTo] + if ch == nil { + panic("SetStatus is called for recipient that was not specified before") + } + + select { + case ch <- err: + default: + // There enough buffer space to fit all statuses at once, if this is + // not the case - backend is doing something wrong. + panic("SetStatus is called more times than particular recipient was specified") + } +} + +func (c *Conn) handleDataLMTP() { + r := newDataReader(c) + status := c.createStatusCollector() + + done := make(chan bool, 1) + + lmtpSession, ok := c.Session().(LMTPSession) + if !ok { + // Fallback to using a single status for all recipients. + err := c.Session().Data(r) + io.Copy(ioutil.Discard, r) // Make sure all the data has been consumed + for _, rcpt := range c.recipients { + status.SetStatus(rcpt, err) + } + done <- true + } else { + go func() { + defer func() { + if err := recover(); err != nil { + status.fillRemaining(&SMTPError{ + Code: 421, + EnhancedCode: EnhancedCode{4, 0, 0}, + Message: "Internal server error", + }) + + stack := debug.Stack() + c.server.ErrorLog.Printf(c, "panic serving %v: %w\n%s", c.State().RemoteAddr, err, stack) + done <- false + } + }() + + status.fillRemaining(lmtpSession.LMTPData(r, status)) + io.Copy(ioutil.Discard, r) // Make sure all the data has been consumed + done <- true + }() + } + + for i, rcpt := range c.recipients { + code, enchCode, msg := toSMTPStatus(<-status.status[i]) + c.WriteResponse(code, enchCode, "<"+rcpt+"> "+msg) + } + + // If done gets false, the panic occured in LMTPData and the connection + // should be closed. + if !<-done { + c.Close() + } +} + +func toSMTPStatus(err error) (code int, enchCode EnhancedCode, msg string) { + if err != nil { + if smtperr, ok := err.(*SMTPError); ok { + return smtperr.Code, smtperr.EnhancedCode, smtperr.Message + } else { + return 554, EnhancedCode{5, 0, 0}, "Error: transaction failed, blame it on the weather: " + err.Error() + } + } + + return 250, EnhancedCode{2, 0, 0}, "OK: queued" +} + +func (c *Conn) Reject() { + c.WriteResponse(421, EnhancedCode{4, 4, 5}, "Too busy. Try again later.") + c.Close() +} + +func (c *Conn) greet() { + c.WriteResponse(220, NoEnhancedCode, fmt.Sprintf("%v ESMTP Service Ready", c.server.Domain)) +} + +func (c *Conn) WriteResponse(code int, enhCode EnhancedCode, text ...string) { + // TODO: error handling + if c.server.WriteTimeout != 0 { + c.conn.SetWriteDeadline(time.Now().Add(c.server.WriteTimeout)) + } + + // All responses must include an enhanced code, if it is missing - use + // a generic code X.0.0. + if enhCode == EnhancedCodeNotSet { + cat := code / 100 + switch cat { + case 2, 4, 5: + enhCode = EnhancedCode{cat, 0, 0} + default: + enhCode = NoEnhancedCode + } + } + + for i := 0; i < len(text)-1; i++ { + c.text.PrintfLine("%d-%v", code, text[i]) + } + if enhCode == NoEnhancedCode { + c.text.PrintfLine("%d %v", code, text[len(text)-1]) + } else { + c.text.PrintfLine("%d %v.%v.%v %v", code, enhCode[0], enhCode[1], enhCode[2], text[len(text)-1]) + } +} + +// Reads a line of input +func (c *Conn) ReadLine() (string, error) { + if c.server.ReadTimeout != 0 { + if err := c.conn.SetReadDeadline(time.Now().Add(c.server.ReadTimeout)); err != nil { + return "", err + } + } + + return c.text.ReadLine() +} + +func (c *Conn) reset() { + c.locker.Lock() + defer c.locker.Unlock() + + if c.bdatPipe != nil { + c.bdatPipe.CloseWithError(ErrDataReset) + c.bdatPipe = nil + } + c.bdatStatus = nil + c.bytesReceived = 0 + + if c.session != nil { + c.session.Reset() + } + + c.fromReceived = false + c.recipients = nil +} diff --git a/vendor/github.com/emersion/go-smtp/data.go b/vendor/github.com/emersion/go-smtp/data.go new file mode 100644 index 0000000..c338455 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/data.go @@ -0,0 +1,147 @@ +package smtp + +import ( + "bufio" + "io" +) + +type EnhancedCode [3]int + +// SMTPError specifies the error code, enhanced error code (if any) and +// message returned by the server. +type SMTPError struct { + Code int + EnhancedCode EnhancedCode + Message string +} + +// NoEnhancedCode is used to indicate that enhanced error code should not be +// included in response. +// +// Note that RFC 2034 requires an enhanced code to be included in all 2xx, 4xx +// and 5xx responses. This constant is exported for use by extensions, you +// should probably use EnhancedCodeNotSet instead. +var NoEnhancedCode = EnhancedCode{-1, -1, -1} + +// EnhancedCodeNotSet is a nil value of EnhancedCode field in SMTPError, used +// to indicate that backend failed to provide enhanced status code. X.0.0 will +// be used (X is derived from error code). +var EnhancedCodeNotSet = EnhancedCode{0, 0, 0} + +func (err *SMTPError) Error() string { + return err.Message +} + +func (err *SMTPError) Temporary() bool { + return err.Code/100 == 4 +} + +var ErrDataTooLarge = &SMTPError{ + Code: 552, + EnhancedCode: EnhancedCode{5, 3, 4}, + Message: "Maximum message size exceeded", +} + +type dataReader struct { + r *bufio.Reader + state int + + limited bool + n int64 // Maximum bytes remaining +} + +func newDataReader(c *Conn) *dataReader { + dr := &dataReader{ + r: c.text.R, + } + + if c.server.MaxMessageBytes > 0 { + dr.limited = true + dr.n = int64(c.server.MaxMessageBytes) + } + + return dr +} + +func (r *dataReader) Read(b []byte) (n int, err error) { + if r.limited { + if r.n <= 0 { + return 0, ErrDataTooLarge + } + if int64(len(b)) > r.n { + b = b[0:r.n] + } + } + + // Code below is taken from net/textproto with only one modification to + // not rewrite CRLF -> LF. + + // Run data through a simple state machine to + // elide leading dots and detect ending .\r\n line. + const ( + stateBeginLine = iota // beginning of line; initial state; must be zero + stateDot // read . at beginning of line + stateDotCR // read .\r at beginning of line + stateCR // read \r (possibly at end of line) + stateData // reading data in middle of line + stateEOF // reached .\r\n end marker line + ) + for n < len(b) && r.state != stateEOF { + var c byte + c, err = r.r.ReadByte() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + break + } + switch r.state { + case stateBeginLine: + if c == '.' { + r.state = stateDot + continue + } + r.state = stateData + case stateDot: + if c == '\r' { + r.state = stateDotCR + continue + } + if c == '\n' { + r.state = stateEOF + continue + } + + r.state = stateData + case stateDotCR: + if c == '\n' { + r.state = stateEOF + continue + } + r.state = stateData + case stateCR: + if c == '\n' { + r.state = stateBeginLine + break + } + r.state = stateData + case stateData: + if c == '\r' { + r.state = stateCR + } + if c == '\n' { + r.state = stateBeginLine + } + } + b[n] = c + n++ + } + if err == nil && r.state == stateEOF { + err = io.EOF + } + + if r.limited { + r.n -= int64(n) + } + return +} diff --git a/vendor/github.com/emersion/go-smtp/go.mod b/vendor/github.com/emersion/go-smtp/go.mod new file mode 100644 index 0000000..88097d6 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/go.mod @@ -0,0 +1,5 @@ +module github.com/emersion/go-smtp + +require github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + +go 1.13 diff --git a/vendor/github.com/emersion/go-smtp/go.sum b/vendor/github.com/emersion/go-smtp/go.sum new file mode 100644 index 0000000..8e0463a --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/go.sum @@ -0,0 +1,2 @@ +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= diff --git a/vendor/github.com/emersion/go-smtp/lengthlimit_reader.go b/vendor/github.com/emersion/go-smtp/lengthlimit_reader.go new file mode 100644 index 0000000..695bc76 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/lengthlimit_reader.go @@ -0,0 +1,47 @@ +package smtp + +import ( + "errors" + "io" +) + +var ErrTooLongLine = errors.New("smtp: too longer line in input stream") + +// lineLimitReader reads from the underlying Reader but restricts +// line length of lines in input stream to a certain length. +// +// If line length exceeds the limit - Read returns ErrTooLongLine +type lineLimitReader struct { + R io.Reader + LineLimit int + + curLineLength int +} + +func (r *lineLimitReader) Read(b []byte) (int, error) { + if r.curLineLength > r.LineLimit && r.LineLimit > 0 { + return 0, ErrTooLongLine + } + + n, err := r.R.Read(b) + if err != nil { + return n, err + } + + if r.LineLimit == 0 { + return n, nil + } + + for _, chr := range b[:n] { + if chr == '\n' { + r.curLineLength = 0 + } + r.curLineLength++ + + if r.curLineLength > r.LineLimit { + return 0, ErrTooLongLine + } + } + + return n, nil +} diff --git a/vendor/github.com/emersion/go-smtp/parse.go b/vendor/github.com/emersion/go-smtp/parse.go new file mode 100644 index 0000000..df73786 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/parse.go @@ -0,0 +1,70 @@ +package smtp + +import ( + "fmt" + "strings" +) + +func parseCmd(line string) (cmd string, arg string, err error) { + line = strings.TrimRight(line, "\r\n") + + l := len(line) + switch { + case strings.HasPrefix(strings.ToUpper(line), "STARTTLS"): + return "STARTTLS", "", nil + case l == 0: + return "", "", nil + case l < 4: + return "", "", fmt.Errorf("Command too short: %q", line) + case l == 4: + return strings.ToUpper(line), "", nil + case l == 5: + // Too long to be only command, too short to have args + return "", "", fmt.Errorf("Mangled command: %q", line) + } + + // If we made it here, command is long enough to have args + if line[4] != ' ' { + // There wasn't a space after the command? + return "", "", fmt.Errorf("Mangled command: %q", line) + } + + // I'm not sure if we should trim the args or not, but we will for now + //return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), nil + return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " \n\r"), nil +} + +// Takes the arguments proceeding a command and files them +// into a map[string]string after uppercasing each key. Sample arg +// string: +// " BODY=8BITMIME SIZE=1024 SMTPUTF8" +// The leading space is mandatory. +func parseArgs(args []string) (map[string]string, error) { + argMap := map[string]string{} + for _, arg := range args { + if arg == "" { + continue + } + m := strings.Split(arg, "=") + switch len(m) { + case 2: + argMap[strings.ToUpper(m[0])] = m[1] + case 1: + argMap[strings.ToUpper(m[0])] = "" + default: + return nil, fmt.Errorf("Failed to parse arg string: %q", arg) + } + } + return argMap, nil +} + +func parseHelloArgument(arg string) (string, error) { + domain := arg + if idx := strings.IndexRune(arg, ' '); idx >= 0 { + domain = arg[:idx] + } + if domain == "" { + return "", fmt.Errorf("Invalid domain") + } + return domain, nil +} diff --git a/vendor/github.com/emersion/go-smtp/server.go b/vendor/github.com/emersion/go-smtp/server.go new file mode 100644 index 0000000..b526466 --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/server.go @@ -0,0 +1,318 @@ +package smtp + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "sync" + "syscall" + "time" + + "github.com/emersion/go-sasl" +) + +var errTCPAndLMTP = errors.New("smtp: cannot start LMTP server listening on a TCP socket") + +// A function that creates SASL servers. +type SaslServerFactory func(conn *Conn) sasl.Server + +// Logger interface is used by Server to report unexpected internal errors. +type Logger interface { + Printf(c *Conn, format string, v ...interface{}) + Println(c *Conn, v ...interface{}) +} + +type DefaultLogger struct { + *log.Logger +} + +func (l *DefaultLogger) Printf(_ *Conn, format string, v ...interface{}) { + l.Logger.Println(fmt.Errorf(format, v...)) +} + +func (l *DefaultLogger) Println(_ *Conn, v ...interface{}) { + l.Logger.Println(v...) +} + +// A SMTP server. +type Server struct { + // TCP or Unix address to listen on. + Addr string + // The server TLS configuration. + TLSConfig *tls.Config + // Enable LMTP mode, as defined in RFC 2033. LMTP mode cannot be used with a + // TCP listener. + LMTP bool + + Domain string + MaxRecipients int + MaxMessageBytes int + MaxLineLength int + AllowInsecureAuth bool + Strict bool + Debug io.Writer + ErrorLog Logger + ReadTimeout time.Duration + WriteTimeout time.Duration + + // Advertise SMTPUTF8 (RFC 6531) capability. + // Should be used only if backend supports it. + EnableSMTPUTF8 bool + + // Advertise REQUIRETLS (RFC 8689) capability. + // Should be used only if backend supports it. + EnableREQUIRETLS bool + + // Advertise BINARYMIME (RFC 3030) capability. + // Should be used only if backend supports it. + EnableBINARYMIME bool + + // If set, the AUTH command will not be advertised and authentication + // attempts will be rejected. This setting overrides AllowInsecureAuth. + AuthDisabled bool + + // The server backend. + Backend Backend + + caps []string + auths map[string]SaslServerFactory + done chan struct{} + + locker sync.Mutex + listeners []net.Listener + conns map[*Conn]struct{} +} + +// New creates a new SMTP server. +func NewServer(be Backend) *Server { + return &Server{ + // Doubled maximum line length per RFC 5321 (Section 4.5.3.1.6) + MaxLineLength: 2000, + + Backend: be, + done: make(chan struct{}, 1), + ErrorLog: &DefaultLogger{log.New(os.Stderr, "smtp/server ", log.LstdFlags)}, + caps: []string{"PIPELINING", "8BITMIME", "ENHANCEDSTATUSCODES", "CHUNKING"}, + auths: map[string]SaslServerFactory{ + sasl.Plain: func(conn *Conn) sasl.Server { + return sasl.NewPlainServer(func(identity, username, password string) error { + if identity != "" && identity != username { + return errors.New("Identities not supported") + } + + state := conn.State() + session, err := be.Login(&state, username, password) + if err != nil { + return err + } + + conn.SetSession(session) + return nil + }) + }, + }, + conns: make(map[*Conn]struct{}), + } +} + +// Serve accepts incoming connections on the Listener l. +func (s *Server) Serve(l net.Listener) error { + s.locker.Lock() + s.listeners = append(s.listeners, l) + s.locker.Unlock() + + for { + c, err := l.Accept() + if err != nil { + select { + case <-s.done: + // we called Close() + return nil + default: + return err + } + } + + go s.handleConn(newConn(c, s)) + } +} + +func (s *Server) handleConn(c *Conn) error { + s.locker.Lock() + s.conns[c] = struct{}{} + s.locker.Unlock() + + defer func() { + c.Close() + + s.locker.Lock() + delete(s.conns, c) + s.locker.Unlock() + }() + + if tlsConn, ok := c.conn.(*tls.Conn); ok { + if d := s.ReadTimeout; d != 0 { + c.conn.SetReadDeadline(time.Now().Add(d)) + } + if d := s.WriteTimeout; d != 0 { + c.conn.SetWriteDeadline(time.Now().Add(d)) + } + if err := tlsConn.Handshake(); err != nil { + if err == io.EOF { + return nil + } + if err, ok := err.(*net.OpError); ok { + // preserve remote address from PROXY protocol + err.Addr = c.conn.RemoteAddr() + } + s.ErrorLog.Printf(c, "TLS handshake error: %w", err) + return err + } + } + + c.greet() + + for { + line, err := c.ReadLine() + if err == nil { + cmd, arg, err := parseCmd(line) + if err != nil { + msg := "Bad command" + s.ErrorLog.Printf(c, "%s: %w", msg, err) + c.protocolError(501, EnhancedCode{5, 5, 2}, msg) + continue + } + + c.handle(cmd, arg) + } else { + if err == io.EOF { + return nil + } + + if err == ErrTooLongLine { + msg := "Too long line, closing connection" + s.ErrorLog.Printf(c, "%s: %w", msg, err) + c.WriteResponse(500, EnhancedCode{5, 4, 0}, msg) + return nil + } + + if err, ok := err.(*net.OpError); ok { + if err.Err == net.ErrClosed { + return nil + } + if errors.Is(err, syscall.ECONNRESET) && c.Session() == nil { + // healthcheck monitor + return nil + } + // preserve remote address from PROXY protocol + err.Addr = c.conn.RemoteAddr() + } + + if neterr, ok := err.(net.Error); ok && neterr.Timeout() { + msg := "Idle timeout, bye bye" + s.ErrorLog.Printf(c, "%s: %w", msg, err) + c.WriteResponse(221, EnhancedCode{2, 4, 2}, msg) + return nil + } + + msg := "Connection error, sorry" + s.ErrorLog.Printf(c, "%s: %w", msg, err) + c.WriteResponse(221, EnhancedCode{2, 4, 0}, msg) + return err + } + } +} + +// ListenAndServe listens on the network address s.Addr and then calls Serve +// to handle requests on incoming connections. +// +// If s.Addr is blank and LMTP is disabled, ":smtp" is used. +func (s *Server) ListenAndServe() error { + network := "tcp" + if s.LMTP { + network = "unix" + } + + addr := s.Addr + if !s.LMTP && addr == "" { + addr = ":smtp" + } + + l, err := net.Listen(network, addr) + if err != nil { + return err + } + + return s.Serve(l) +} + +// ListenAndServeTLS listens on the TCP network address s.Addr and then calls +// Serve to handle requests on incoming TLS connections. +// +// If s.Addr is blank, ":smtps" is used. +func (s *Server) ListenAndServeTLS() error { + if s.LMTP { + return errTCPAndLMTP + } + + addr := s.Addr + if addr == "" { + addr = ":smtps" + } + + l, err := tls.Listen("tcp", addr, s.TLSConfig) + if err != nil { + return err + } + + return s.Serve(l) +} + +// Close immediately closes all active listeners and connections. +// +// Close returns any error returned from closing the server's underlying +// listener(s). +func (s *Server) Close() error { + select { + case <-s.done: + return errors.New("smtp: server already closed") + default: + close(s.done) + } + + var err error + s.locker.Lock() + for _, l := range s.listeners { + if lerr := l.Close(); lerr != nil && err == nil { + err = lerr + } + } + + for conn := range s.conns { + conn.Close() + } + s.locker.Unlock() + + return err +} + +// EnableAuth enables an authentication mechanism on this server. +// +// This function should not be called directly, it must only be used by +// libraries implementing extensions of the SMTP protocol. +func (s *Server) EnableAuth(name string, f SaslServerFactory) { + s.auths[name] = f +} + +// ForEachConn iterates through all opened connections. +func (s *Server) ForEachConn(f func(*Conn)) { + s.locker.Lock() + defer s.locker.Unlock() + for conn := range s.conns { + f(conn) + } +} diff --git a/vendor/github.com/emersion/go-smtp/smtp.go b/vendor/github.com/emersion/go-smtp/smtp.go new file mode 100644 index 0000000..65aba8e --- /dev/null +++ b/vendor/github.com/emersion/go-smtp/smtp.go @@ -0,0 +1,30 @@ +// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321. +// +// It also implements the following extensions: +// +// 8BITMIME: RFC 1652 +// AUTH: RFC 2554 +// STARTTLS: RFC 3207 +// ENHANCEDSTATUSCODES: RFC 2034 +// SMTPUTF8: RFC 6531 +// REQUIRETLS: RFC 8689 +// CHUNKING: RFC 3030 +// BINARYMIME: RFC 3030 +// +// LMTP (RFC 2033) is also supported. +// +// Additional extensions may be handled by other packages. +package smtp + +import ( + "errors" + "strings" +) + +// validateLine checks to see if a line has CR or LF as per RFC 5321 +func validateLine(line string) error { + if strings.ContainsAny(line, "\n\r") { + return errors.New("smtp: A line must not contain CR or LF") + } + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt new file mode 100644 index 0000000..a3aef2d --- /dev/null +++ b/vendor/modules.txt @@ -0,0 +1,7 @@ +# github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 +## explicit +github.com/emersion/go-sasl +# github.com/emersion/go-smtp v0.15.1-0.20211103212524-30169acc42e7 => github.com/kayrus/go-smtp v0.15.1-0.20211216174341-f5f4e119d8cc +## explicit +github.com/emersion/go-smtp +# github.com/emersion/go-smtp => github.com/kayrus/go-smtp v0.15.1-0.20211216174341-f5f4e119d8cc