├── .circleci └── config.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── auth.go ├── auth_test.go ├── doc.go ├── errors.go ├── example_test.go ├── go.mod ├── go.sum ├── message.go ├── message_test.go ├── mime.go ├── mime_go14.go ├── send.go ├── send_test.go ├── smtp.go ├── smtp_test.go └── writeto.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | test: 5 | docker: 6 | - 7 | image: circleci/golang:1.14 8 | steps: 9 | - checkout 10 | - setup_remote_docker 11 | - run: go test ./... 12 | 13 | workflows: 14 | test: 15 | jobs: 16 | - test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | 16 | # IDE's 17 | .idea/ 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## *Unreleased* 6 | 7 | ## [2.3.1] - 2018-11-12 8 | 9 | ### Fixed 10 | 11 | - #39: Reverts addition of Go modules `go.mod` manifest. 12 | 13 | ## [2.3.0] - 2018-11-10 14 | 15 | ### Added 16 | 17 | - #12: Adds `SendError` to provide additional info about the cause and index of 18 | a failed attempt to transmit a batch of messages. 19 | - go-gomail#78: Adds new `Message` methods for attaching and embedding 20 | `io.Reader`s: `AttachReader` and `EmbedReader`. 21 | 22 | ### Fixed 23 | 24 | - #26: Fixes RFC 1341 compliance by properly capitalizing the 25 | `MIME-Version` header. 26 | - #30: Fixes IO errors being silently dropped in `Message.WriteTo`. 27 | 28 | ## [2.2.0] - 2018-03-01 29 | 30 | ### Added 31 | 32 | - #20: Adds `Message.SetBoundary` to allow specifying a custom MIME boundary. 33 | - #22: Adds `Message.SetBodyWriter` to make it easy to use text/template and 34 | html/template for message bodies. Contributed by Quantcast. 35 | - #25: Adds `Dialer.StartTLSPolicy` so that `MandatoryStartTLS` can be required, 36 | or `NoStartTLS` can disable it. Contributed by Quantcast. 37 | 38 | ## [2.1.0] - 2017-12-14 39 | 40 | ### Added 41 | 42 | - go-gomail#40: Adds `Dialer.LocalName` field to allow specifying the hostname 43 | sent with SMTP's HELO command. 44 | - go-gomail#47: `Message.SetBody`, `Message.AddAlternative`, and 45 | `Message.AddAlternativeWriter` allow specifying the encoding of message parts. 46 | - `Dialer.Dial`'s returned `SendCloser` automatically redials after a timeout. 47 | - go-gomail#55, go-gomail#56: Adds `Rename` to allow specifying filename 48 | of an attachment. 49 | - go-gomail#100: Exports `NetDialTimeout` to allow setting a custom dialer. 50 | - go-gomail#70: Adds `Dialer.Timeout` field to allow specifying a timeout for 51 | dials, reads, and writes. 52 | 53 | ### Changed 54 | 55 | - go-gomail#52: `Dialer.Dial` automatically uses CRAM-MD5 when available. 56 | - `Dialer.Dial` specifies a default timeout of 10 seconds. 57 | - Gomail is forked from to 58 | . 59 | 60 | ### Deprecated 61 | 62 | - go-gomail#52: `NewPlainDialer` is deprecated in favor of `NewDialer`. 63 | 64 | ### Fixed 65 | 66 | - go-gomail#41, go-gomail#42: Fixes a panic when a `Message` contains a 67 | nil header. 68 | - go-gomail#44: Fixes `AddAlternativeWriter` replacing the message body instead 69 | of adding a body part. 70 | - go-gomail#53: Folds long header lines for RFC 2047 compliance. 71 | - go-gomail#54: Fixes `Message.FormatAddress` when name is blank. 72 | 73 | ## [2.0.0] - 2015-09-02 74 | 75 | - Mailer has been removed. It has been replaced by Dialer and Sender. 76 | - `File` type and the `CreateFile` and `OpenFile` functions have been removed. 77 | - `Message.Attach` and `Message.Embed` have a new signature. 78 | - `Message.GetBodyWriter` has been removed. Use `Message.AddAlternativeWriter` 79 | instead. 80 | - `Message.Export` has been removed. `Message.WriteTo` can be used instead. 81 | - `Message.DelHeader` has been removed. 82 | - The `Bcc` header field is no longer sent. It is far more simpler and 83 | efficient: the same message is sent to all recipients instead of sending a 84 | different email to each Bcc address. 85 | - LoginAuth has been removed. `NewPlainDialer` now implements the LOGIN 86 | authentication mechanism when needed. 87 | - Go 1.2 is now required instead of Go 1.3. No external dependency are used when 88 | using Go 1.5. 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thank you for contributing to Gomail! Here are a few guidelines: 2 | 3 | ## Bugs 4 | 5 | If you think you found a bug, create an issue and supply the minimum amount 6 | of code triggering the bug so it can be reproduced. 7 | 8 | 9 | ## Fixing a bug 10 | 11 | If you want to fix a bug, you can send a pull request. It should contains a 12 | new test or update an existing one to cover that bug. 13 | 14 | 15 | ## New feature proposal 16 | 17 | If you think Gomail lacks a feature, you can open an issue or send a pull 18 | request. I want to keep Gomail code and API as simple as possible so please 19 | describe your needs so we can discuss whether this feature should be added to 20 | Gomail or not. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Alexandre Cesaro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ory/mail 2 | 3 | This is a fork of the abandoned [go-mail/mail](https://github.com/go-mail/mail) which is an 4 | abandoned fork of [go-gomail/gomail](https://github.com/go-gomail/gomail) 5 | 6 | ## Introduction 7 | 8 | ory/mail is a simple and efficient package to send emails. It is well tested and 9 | documented. 10 | 11 | ory/mail can only send emails using an SMTP server. But the API is flexible and it 12 | is easy to implement other methods for sending emails using a local Postfix, an 13 | API, etc. 14 | 15 | ## Features 16 | 17 | ory/mail supports: 18 | - Attachments 19 | - Embedded images 20 | - HTML and text templates 21 | - Automatic encoding of special characters 22 | - SSL and TLS 23 | - Sending multiple emails with the same SMTP connection 24 | 25 | ## Documentation 26 | 27 | https://godoc.org/github.com/go-mail/mail 28 | 29 | ## Use 30 | 31 | ```shell script 32 | $ go get github.com/ory/mail/v3 33 | ``` 34 | 35 | ## FAQ 36 | 37 | ### x509: certificate signed by unknown authority 38 | 39 | If you get this error it means the certificate used by the SMTP server is not 40 | considered valid by the client running ory/mail. As a quick workaround you can 41 | bypass the verification of the server's certificate chain and host name by using 42 | `SetTLSConfig`: 43 | 44 | ```go 45 | package main 46 | 47 | import ( 48 | "crypto/tls" 49 | 50 | "github.com/ory/mail" 51 | ) 52 | 53 | func main() { 54 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 55 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true} 56 | 57 | // Send emails using d. 58 | } 59 | ``` 60 | 61 | Note, however, that this is insecure and should not be used in production. 62 | 63 | ## Contribute 64 | 65 | Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for 66 | more info. 67 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/pkg/errors" 7 | "net/smtp" 8 | ) 9 | 10 | // loginAuth is an smtp.Auth that implements the LOGIN authentication mechanism. 11 | type loginAuth struct { 12 | username string 13 | password string 14 | host string 15 | } 16 | 17 | func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { 18 | if !server.TLS { 19 | advertised := false 20 | for _, mechanism := range server.Auth { 21 | if mechanism == "LOGIN" { 22 | advertised = true 23 | break 24 | } 25 | } 26 | if !advertised { 27 | return "", nil, errors.New("gomail: unencrypted connection") 28 | } 29 | } 30 | if server.Name != a.host { 31 | return "", nil, errors.New("gomail: wrong host name") 32 | } 33 | return "LOGIN", nil, nil 34 | } 35 | 36 | func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { 37 | if !more { 38 | return nil, nil 39 | } 40 | 41 | switch { 42 | case bytes.Equal(fromServer, []byte("Username:")): 43 | return []byte(a.username), nil 44 | case bytes.Equal(fromServer, []byte("Password:")): 45 | return []byte(a.password), nil 46 | default: 47 | return nil, fmt.Errorf("gomail: unexpected server challenge: %s", fromServer) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "net/smtp" 5 | "testing" 6 | ) 7 | 8 | const ( 9 | testUser = "user" 10 | testPwd = "pwd" 11 | testHost = "smtp.example.com" 12 | ) 13 | 14 | type authTest struct { 15 | auths []string 16 | challenges []string 17 | tls bool 18 | wantData []string 19 | wantError bool 20 | } 21 | 22 | func TestNoAdvertisement(t *testing.T) { 23 | testLoginAuth(t, &authTest{ 24 | auths: []string{}, 25 | tls: false, 26 | wantError: true, 27 | }) 28 | } 29 | 30 | func TestNoAdvertisementTLS(t *testing.T) { 31 | testLoginAuth(t, &authTest{ 32 | auths: []string{}, 33 | challenges: []string{"Username:", "Password:"}, 34 | tls: true, 35 | wantData: []string{"", testUser, testPwd}, 36 | }) 37 | } 38 | 39 | func TestLogin(t *testing.T) { 40 | testLoginAuth(t, &authTest{ 41 | auths: []string{"PLAIN", "LOGIN"}, 42 | challenges: []string{"Username:", "Password:"}, 43 | tls: false, 44 | wantData: []string{"", testUser, testPwd}, 45 | }) 46 | } 47 | 48 | func TestLoginTLS(t *testing.T) { 49 | testLoginAuth(t, &authTest{ 50 | auths: []string{"LOGIN"}, 51 | challenges: []string{"Username:", "Password:"}, 52 | tls: true, 53 | wantData: []string{"", testUser, testPwd}, 54 | }) 55 | } 56 | 57 | func testLoginAuth(t *testing.T, test *authTest) { 58 | auth := &loginAuth{ 59 | username: testUser, 60 | password: testPwd, 61 | host: testHost, 62 | } 63 | server := &smtp.ServerInfo{ 64 | Name: testHost, 65 | TLS: test.tls, 66 | Auth: test.auths, 67 | } 68 | proto, toServer, err := auth.Start(server) 69 | if err != nil && !test.wantError { 70 | t.Fatalf("loginAuth.Start(): %v", err) 71 | } 72 | if err != nil && test.wantError { 73 | return 74 | } 75 | if proto != "LOGIN" { 76 | t.Errorf("invalid protocol, got %q, want LOGIN", proto) 77 | } 78 | 79 | i := 0 80 | got := string(toServer) 81 | if got != test.wantData[i] { 82 | t.Errorf("Invalid response, got %q, want %q", got, test.wantData[i]) 83 | } 84 | 85 | for _, challenge := range test.challenges { 86 | i++ 87 | if i >= len(test.wantData) { 88 | t.Fatalf("unexpected challenge: %q", challenge) 89 | } 90 | 91 | toServer, err = auth.Next([]byte(challenge), true) 92 | if err != nil { 93 | t.Fatalf("loginAuth.Auth(): %v", err) 94 | } 95 | got = string(toServer) 96 | if got != test.wantData[i] { 97 | t.Errorf("Invalid response, got %q, want %q", got, test.wantData[i]) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package gomail provides a simple interface to compose emails and to mail them 2 | // efficiently. 3 | // 4 | // More info on Github: https://github.com/go-mail/mail 5 | // 6 | package mail 7 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import "fmt" 4 | 5 | // A SendError represents the failure to transmit a Message, detailing the cause 6 | // of the failure and index of the Message within a batch. 7 | type SendError struct { 8 | // Index specifies the index of the Message within a batch. 9 | Index uint 10 | Cause error 11 | } 12 | 13 | func (err *SendError) Error() string { 14 | return fmt.Sprintf("gomail: could not send email %d: %v", 15 | err.Index+1, err.Cause) 16 | } 17 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package mail_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "io" 8 | "log" 9 | "time" 10 | 11 | "github.com/ory/mail/v3" 12 | ) 13 | 14 | func Example() { 15 | m := mail.NewMessage() 16 | m.SetHeader("From", "alex@example.com") 17 | m.SetHeader("To", "bob@example.com", "cora@example.com") 18 | m.SetAddressHeader("Cc", "dan@example.com", "Dan") 19 | m.SetHeader("Subject", "Hello!") 20 | m.SetBody("text/html", "Hello Bob and Cora!") 21 | m.Attach("/home/Alex/lolcat.jpg") 22 | 23 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 24 | d.StartTLSPolicy = mail.MandatoryStartTLS 25 | 26 | // Send the email to Bob, Cora and Dan. 27 | if err := d.DialAndSend(context.Background(), m); err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | // A daemon that listens to a channel and sends all incoming messages. 33 | func Example_daemon() { 34 | ch := make(chan *mail.Message) 35 | 36 | go func() { 37 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 38 | d.StartTLSPolicy = mail.MandatoryStartTLS 39 | 40 | var s mail.SendCloser 41 | var err error 42 | open := false 43 | for { 44 | select { 45 | case m, ok := <-ch: 46 | if !ok { 47 | return 48 | } 49 | if !open { 50 | if s, err = d.Dial(context.Background()); err != nil { 51 | panic(err) 52 | } 53 | open = true 54 | } 55 | if err := mail.Send(context.Background(), s, m); err != nil { 56 | log.Print(err) 57 | } 58 | // Close the connection to the SMTP server if no email was sent in 59 | // the last 30 seconds. 60 | case <-time.After(30 * time.Second): 61 | if open { 62 | if err := s.Close(); err != nil { 63 | panic(err) 64 | } 65 | open = false 66 | } 67 | } 68 | } 69 | }() 70 | 71 | // Use the channel in your program to send emails. 72 | 73 | // Close the channel to stop the mail daemon. 74 | close(ch) 75 | } 76 | 77 | // Efficiently send a customized newsletter to a list of recipients. 78 | func Example_newsletter() { 79 | // The list of recipients. 80 | var list []struct { 81 | Name string 82 | Address string 83 | } 84 | 85 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 86 | d.StartTLSPolicy = mail.MandatoryStartTLS 87 | s, err := d.Dial(context.Background()) 88 | if err != nil { 89 | panic(err) 90 | } 91 | 92 | m := mail.NewMessage() 93 | for _, r := range list { 94 | m.SetHeader("From", "no-reply@example.com") 95 | m.SetAddressHeader("To", r.Address, r.Name) 96 | m.SetHeader("Subject", "Newsletter #1") 97 | m.SetBody("text/html", fmt.Sprintf("Hello %s!", r.Name)) 98 | 99 | if err := mail.Send(context.Background(), s, m); err != nil { 100 | log.Printf("Could not send email to %q: %v", r.Address, err) 101 | } 102 | m.Reset() 103 | } 104 | } 105 | 106 | // Send an email using a local SMTP server. 107 | func Example_noAuth() { 108 | m := mail.NewMessage() 109 | m.SetHeader("From", "from@example.com") 110 | m.SetHeader("To", "to@example.com") 111 | m.SetHeader("Subject", "Hello!") 112 | m.SetBody("text/plain", "Hello!") 113 | 114 | d := mail.Dialer{Host: "localhost", Port: 587} 115 | if err := d.DialAndSend(context.Background(), m); err != nil { 116 | panic(err) 117 | } 118 | } 119 | 120 | // Send an email using an API or postfix. 121 | func Example_noSMTP() { 122 | m := mail.NewMessage() 123 | m.SetHeader("From", "from@example.com") 124 | m.SetHeader("To", "to@example.com") 125 | m.SetHeader("Subject", "Hello!") 126 | m.SetBody("text/plain", "Hello!") 127 | 128 | s := mail.SendFunc(func(ctx context.Context, from string, to []string, msg io.WriterTo) error { 129 | // Implements you email-sending function, for example by calling 130 | // an API, or running postfix, etc. 131 | fmt.Println("From:", from) 132 | fmt.Println("To:", to) 133 | return nil 134 | }) 135 | 136 | if err := mail.Send(context.Background(), s, m); err != nil { 137 | panic(err) 138 | } 139 | // Output: 140 | // From: from@example.com 141 | // To: [to@example.com] 142 | } 143 | 144 | var m *mail.Message 145 | 146 | func ExampleSetCopyFunc() { 147 | m.Attach("foo.txt", mail.SetCopyFunc(func(w io.Writer) error { 148 | _, err := w.Write([]byte("Content of foo.txt")) 149 | return err 150 | })) 151 | } 152 | 153 | func ExampleSetHeader() { 154 | h := map[string][]string{"Content-ID": {""}} 155 | m.Attach("foo.jpg", mail.SetHeader(h)) 156 | } 157 | 158 | func ExampleRename() { 159 | m.Attach("/tmp/0000146.jpg", mail.Rename("picture.jpg")) 160 | } 161 | 162 | func ExampleMessage_AddAlternative() { 163 | m.SetBody("text/plain", "Hello!") 164 | m.AddAlternative("text/html", "

Hello!

") 165 | } 166 | 167 | func ExampleMessage_AddAlternativeWriter() { 168 | t := template.Must(template.New("example").Parse("Hello {{.}}!")) 169 | m.AddAlternativeWriter("text/plain", func(w io.Writer) error { 170 | return t.Execute(w, "Bob") 171 | }) 172 | } 173 | 174 | func ExampleMessage_Attach() { 175 | m.Attach("/tmp/image.jpg") 176 | } 177 | 178 | func ExampleMessage_Embed() { 179 | m.Embed("/tmp/image.jpg") 180 | m.SetBody("text/html", `My image`) 181 | } 182 | 183 | func ExampleMessage_FormatAddress() { 184 | m.SetHeader("To", m.FormatAddress("bob@example.com", "Bob"), m.FormatAddress("cora@example.com", "Cora")) 185 | } 186 | 187 | func ExampleMessage_FormatDate() { 188 | m.SetHeaders(map[string][]string{ 189 | "X-Date": {m.FormatDate(time.Now())}, 190 | }) 191 | } 192 | 193 | func ExampleMessage_SetAddressHeader() { 194 | m.SetAddressHeader("To", "bob@example.com", "Bob") 195 | } 196 | 197 | func ExampleMessage_SetBody() { 198 | m.SetBody("text/plain", "Hello!") 199 | } 200 | 201 | func ExampleMessage_SetBodyWriter() { 202 | t := template.Must(template.New("example").Parse("Hello {{.}}!")) 203 | m.SetBodyWriter("text/plain", func(w io.Writer) error { 204 | return t.Execute(w, "Bob") 205 | }) 206 | } 207 | 208 | func ExampleMessage_SetDateHeader() { 209 | m.SetDateHeader("X-Date", time.Now()) 210 | } 211 | 212 | func ExampleMessage_SetHeader() { 213 | m.SetHeader("Subject", "Hello!") 214 | } 215 | 216 | func ExampleMessage_SetHeaders() { 217 | m.SetHeaders(map[string][]string{ 218 | "From": {m.FormatAddress("alex@example.com", "Alex")}, 219 | "To": {"bob@example.com", "cora@example.com"}, 220 | "Subject": {"Hello"}, 221 | }) 222 | } 223 | 224 | func ExampleSetCharset() { 225 | m = mail.NewMessage(mail.SetCharset("ISO-8859-1")) 226 | } 227 | 228 | func ExampleSetEncoding() { 229 | m = mail.NewMessage(mail.SetEncoding(mail.Base64)) 230 | } 231 | 232 | func ExampleSetPartEncoding() { 233 | m.SetBody("text/plain", "Hello!", mail.SetPartEncoding(mail.Unencoded)) 234 | } 235 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ory/mail/v3 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/pkg/errors v0.9.1 7 | github.com/stretchr/testify v1.5.1 8 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 4 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 9 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 10 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 11 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 15 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 16 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | ) 10 | 11 | // Message represents an email. 12 | type Message struct { 13 | header header 14 | parts []*part 15 | attachments []*file 16 | embedded []*file 17 | charset string 18 | encoding Encoding 19 | hEncoder mimeEncoder 20 | buf bytes.Buffer 21 | boundary string 22 | } 23 | 24 | type header map[string][]string 25 | 26 | type part struct { 27 | contentType string 28 | copier func(io.Writer) error 29 | encoding Encoding 30 | } 31 | 32 | // NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding 33 | // by default. 34 | func NewMessage(settings ...MessageSetting) *Message { 35 | m := &Message{ 36 | header: make(header), 37 | charset: "UTF-8", 38 | encoding: QuotedPrintable, 39 | } 40 | 41 | m.applySettings(settings) 42 | 43 | if m.encoding == Base64 { 44 | m.hEncoder = bEncoding 45 | } else { 46 | m.hEncoder = qEncoding 47 | } 48 | 49 | return m 50 | } 51 | 52 | // Reset resets the message so it can be reused. The message keeps its previous 53 | // settings so it is in the same state that after a call to NewMessage. 54 | func (m *Message) Reset() { 55 | for k := range m.header { 56 | delete(m.header, k) 57 | } 58 | m.parts = nil 59 | m.attachments = nil 60 | m.embedded = nil 61 | } 62 | 63 | func (m *Message) applySettings(settings []MessageSetting) { 64 | for _, s := range settings { 65 | s(m) 66 | } 67 | } 68 | 69 | // A MessageSetting can be used as an argument in NewMessage to configure an 70 | // email. 71 | type MessageSetting func(m *Message) 72 | 73 | // SetCharset is a message setting to set the charset of the email. 74 | func SetCharset(charset string) MessageSetting { 75 | return func(m *Message) { 76 | m.charset = charset 77 | } 78 | } 79 | 80 | // SetEncoding is a message setting to set the encoding of the email. 81 | func SetEncoding(enc Encoding) MessageSetting { 82 | return func(m *Message) { 83 | m.encoding = enc 84 | } 85 | } 86 | 87 | // Encoding represents a MIME encoding scheme like quoted-printable or base64. 88 | type Encoding string 89 | 90 | const ( 91 | // QuotedPrintable represents the quoted-printable encoding as defined in 92 | // RFC 2045. 93 | QuotedPrintable Encoding = "quoted-printable" 94 | // Base64 represents the base64 encoding as defined in RFC 2045. 95 | Base64 Encoding = "base64" 96 | // Unencoded can be used to avoid encoding the body of an email. The headers 97 | // will still be encoded using quoted-printable encoding. 98 | Unencoded Encoding = "8bit" 99 | ) 100 | 101 | // SetBoundary sets a custom multipart boundary. 102 | func (m *Message) SetBoundary(boundary string) { 103 | m.boundary = boundary 104 | } 105 | 106 | // SetHeader sets a value to the given header field. 107 | func (m *Message) SetHeader(field string, value ...string) { 108 | m.encodeHeader(value) 109 | m.header[field] = m.encodeHeader(value) 110 | } 111 | 112 | func (m *Message) encodeHeader(values []string) []string { 113 | encoded := make([]string, len(values)) 114 | for i := range values { 115 | encoded[i] = m.encodeString(values[i]) 116 | } 117 | 118 | return encoded 119 | } 120 | 121 | func (m *Message) encodeString(value string) string { 122 | return m.hEncoder.Encode(m.charset, value) 123 | } 124 | 125 | // SetHeaders sets the message headers. 126 | func (m *Message) SetHeaders(h map[string][]string) { 127 | for k, v := range h { 128 | m.SetHeader(k, v...) 129 | } 130 | } 131 | 132 | // SetAddressHeader sets an address to the given header field. 133 | func (m *Message) SetAddressHeader(field, address, name string) { 134 | m.header[field] = []string{m.FormatAddress(address, name)} 135 | } 136 | 137 | // FormatAddress formats an address and a name as a valid RFC 5322 address. 138 | func (m *Message) FormatAddress(address, name string) string { 139 | if name == "" { 140 | return address 141 | } 142 | 143 | enc := m.encodeString(name) 144 | if enc == name { 145 | m.buf.WriteByte('"') 146 | for i := 0; i < len(name); i++ { 147 | b := name[i] 148 | if b == '\\' || b == '"' { 149 | m.buf.WriteByte('\\') 150 | } 151 | m.buf.WriteByte(b) 152 | } 153 | m.buf.WriteByte('"') 154 | } else if hasSpecials(name) { 155 | m.buf.WriteString(bEncoding.Encode(m.charset, name)) 156 | } else { 157 | m.buf.WriteString(enc) 158 | } 159 | m.buf.WriteString(" <") 160 | m.buf.WriteString(address) 161 | m.buf.WriteByte('>') 162 | 163 | addr := m.buf.String() 164 | m.buf.Reset() 165 | return addr 166 | } 167 | 168 | func hasSpecials(text string) bool { 169 | for i := 0; i < len(text); i++ { 170 | switch c := text[i]; c { 171 | case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"': 172 | return true 173 | } 174 | } 175 | 176 | return false 177 | } 178 | 179 | // SetDateHeader sets a date to the given header field. 180 | func (m *Message) SetDateHeader(field string, date time.Time) { 181 | m.header[field] = []string{m.FormatDate(date)} 182 | } 183 | 184 | // FormatDate formats a date as a valid RFC 5322 date. 185 | func (m *Message) FormatDate(date time.Time) string { 186 | return date.Format(time.RFC1123Z) 187 | } 188 | 189 | // GetHeader gets a header field. 190 | func (m *Message) GetHeader(field string) []string { 191 | return m.header[field] 192 | } 193 | 194 | // SetBody sets the body of the message. It replaces any content previously set 195 | // by SetBody, SetBodyWriter, AddAlternative or AddAlternativeWriter. 196 | func (m *Message) SetBody(contentType, body string, settings ...PartSetting) { 197 | m.SetBodyWriter(contentType, newCopier(body), settings...) 198 | } 199 | 200 | // SetBodyWriter sets the body of the message. It can be useful with the 201 | // text/template or html/template packages. 202 | func (m *Message) SetBodyWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { 203 | m.parts = []*part{m.newPart(contentType, f, settings)} 204 | } 205 | 206 | // AddAlternative adds an alternative part to the message. 207 | // 208 | // It is commonly used to send HTML emails that default to the plain text 209 | // version for backward compatibility. AddAlternative appends the new part to 210 | // the end of the message. So the plain text part should be added before the 211 | // HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative 212 | func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) { 213 | m.AddAlternativeWriter(contentType, newCopier(body), settings...) 214 | } 215 | 216 | func newCopier(s string) func(io.Writer) error { 217 | return func(w io.Writer) error { 218 | _, err := io.WriteString(w, s) 219 | return err 220 | } 221 | } 222 | 223 | // AddAlternativeWriter adds an alternative part to the message. It can be 224 | // useful with the text/template or html/template packages. 225 | func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { 226 | m.parts = append(m.parts, m.newPart(contentType, f, settings)) 227 | } 228 | 229 | func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part { 230 | p := &part{ 231 | contentType: contentType, 232 | copier: f, 233 | encoding: m.encoding, 234 | } 235 | 236 | for _, s := range settings { 237 | s(p) 238 | } 239 | 240 | return p 241 | } 242 | 243 | // A PartSetting can be used as an argument in Message.SetBody, 244 | // Message.SetBodyWriter, Message.AddAlternative or Message.AddAlternativeWriter 245 | // to configure the part added to a message. 246 | type PartSetting func(*part) 247 | 248 | // SetPartEncoding sets the encoding of the part added to the message. By 249 | // default, parts use the same encoding than the message. 250 | func SetPartEncoding(e Encoding) PartSetting { 251 | return PartSetting(func(p *part) { 252 | p.encoding = e 253 | }) 254 | } 255 | 256 | type file struct { 257 | Name string 258 | Header map[string][]string 259 | CopyFunc func(w io.Writer) error 260 | } 261 | 262 | func (f *file) setHeader(field, value string) { 263 | f.Header[field] = []string{value} 264 | } 265 | 266 | // A FileSetting can be used as an argument in Message.Attach or Message.Embed. 267 | type FileSetting func(*file) 268 | 269 | // SetHeader is a file setting to set the MIME header of the message part that 270 | // contains the file content. 271 | // 272 | // Mandatory headers are automatically added if they are not set when sending 273 | // the email. 274 | func SetHeader(h map[string][]string) FileSetting { 275 | return func(f *file) { 276 | for k, v := range h { 277 | f.Header[k] = v 278 | } 279 | } 280 | } 281 | 282 | // Rename is a file setting to set the name of the attachment if the name is 283 | // different than the filename on disk. 284 | func Rename(name string) FileSetting { 285 | return func(f *file) { 286 | f.Name = name 287 | } 288 | } 289 | 290 | // SetCopyFunc is a file setting to replace the function that runs when the 291 | // message is sent. It should copy the content of the file to the io.Writer. 292 | // 293 | // The default copy function opens the file with the given filename, and copy 294 | // its content to the io.Writer. 295 | func SetCopyFunc(f func(io.Writer) error) FileSetting { 296 | return func(fi *file) { 297 | fi.CopyFunc = f 298 | } 299 | } 300 | 301 | // AttachReader attaches a file using an io.Reader 302 | func (m *Message) AttachReader(name string, r io.Reader, settings ...FileSetting) { 303 | m.attachments = m.appendFile(m.attachments, fileFromReader(name, r), settings) 304 | } 305 | 306 | // Attach attaches the files to the email. 307 | func (m *Message) Attach(filename string, settings ...FileSetting) { 308 | m.attachments = m.appendFile(m.attachments, fileFromFilename(filename), settings) 309 | } 310 | 311 | // EmbedReader embeds the images to the email. 312 | func (m *Message) EmbedReader(name string, r io.Reader, settings ...FileSetting) { 313 | m.embedded = m.appendFile(m.embedded, fileFromReader(name, r), settings) 314 | } 315 | 316 | // Embed embeds the images to the email. 317 | func (m *Message) Embed(filename string, settings ...FileSetting) { 318 | m.embedded = m.appendFile(m.embedded, fileFromFilename(filename), settings) 319 | } 320 | 321 | func fileFromFilename(name string) *file { 322 | return &file{ 323 | Name: filepath.Base(name), 324 | Header: make(map[string][]string), 325 | CopyFunc: func(w io.Writer) error { 326 | h, err := os.Open(name) 327 | if err != nil { 328 | return err 329 | } 330 | if _, err := io.Copy(w, h); err != nil { 331 | h.Close() 332 | return err 333 | } 334 | return h.Close() 335 | }, 336 | } 337 | } 338 | 339 | func fileFromReader(name string, r io.Reader) *file { 340 | return &file{ 341 | Name: filepath.Base(name), 342 | Header: make(map[string][]string), 343 | CopyFunc: func(w io.Writer) error { 344 | if _, err := io.Copy(w, r); err != nil { 345 | return err 346 | } 347 | return nil 348 | }, 349 | } 350 | } 351 | 352 | func (m *Message) appendFile(list []*file, f *file, settings []FileSetting) []*file { 353 | for _, s := range settings { 354 | s(f) 355 | } 356 | 357 | if list == nil { 358 | return []*file{f} 359 | } 360 | 361 | return append(list, f) 362 | } 363 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "io" 8 | "io/ioutil" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | func init() { 20 | now = func() time.Time { 21 | return time.Date(2014, 06, 25, 17, 46, 0, 0, time.UTC) 22 | } 23 | } 24 | 25 | type message struct { 26 | from string 27 | to []string 28 | content string 29 | } 30 | 31 | func TestHeader(t *testing.T) { 32 | original := []string{"foo!", "¡bar", "señor"} 33 | m := NewMessage() 34 | m.SetHeader("Subject", original...) 35 | assert.Equal(t, []string{"foo!", "¡bar", "señor"}, original) 36 | } 37 | 38 | func TestMessage(t *testing.T) { 39 | m := NewMessage() 40 | m.SetAddressHeader("From", "from@example.com", "Señor From") 41 | m.SetHeader("To", m.FormatAddress("to@example.com", "Señor To"), "tobis@example.com") 42 | m.SetAddressHeader("Cc", "cc@example.com", "A, B") 43 | m.SetAddressHeader("X-To", "ccbis@example.com", "à, b") 44 | m.SetDateHeader("X-Date", now()) 45 | m.SetHeader("X-Date-2", m.FormatDate(now())) 46 | m.SetHeader("Subject", "¡Hola, señor!") 47 | m.SetHeaders(map[string][]string{ 48 | "X-Headers": {"Test", "Café"}, 49 | }) 50 | m.SetBody("text/plain", "¡Hola, señor!") 51 | 52 | want := &message{ 53 | from: "from@example.com", 54 | to: []string{ 55 | "to@example.com", 56 | "tobis@example.com", 57 | "cc@example.com", 58 | }, 59 | content: "From: =?UTF-8?q?Se=C3=B1or_From?= \r\n" + 60 | "To: =?UTF-8?q?Se=C3=B1or_To?= , tobis@example.com\r\n" + 61 | "Cc: \"A, B\" \r\n" + 62 | "X-To: =?UTF-8?b?w6AsIGI=?= \r\n" + 63 | "X-Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 64 | "X-Date-2: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 65 | "X-Headers: Test, =?UTF-8?q?Caf=C3=A9?=\r\n" + 66 | "Subject: =?UTF-8?q?=C2=A1Hola,_se=C3=B1or!?=\r\n" + 67 | "Content-Type: text/plain; charset=UTF-8\r\n" + 68 | "Content-Transfer-Encoding: quoted-printable\r\n" + 69 | "\r\n" + 70 | "=C2=A1Hola, se=C3=B1or!", 71 | } 72 | 73 | testMessage(t, m, 0, want) 74 | } 75 | 76 | func TestCustomMessage(t *testing.T) { 77 | m := NewMessage(SetCharset("ISO-8859-1"), SetEncoding(Base64)) 78 | m.SetHeaders(map[string][]string{ 79 | "From": {"from@example.com"}, 80 | "To": {"to@example.com"}, 81 | "Subject": {"Café"}, 82 | }) 83 | m.SetBody("text/html", "¡Hola, señor!") 84 | 85 | want := &message{ 86 | from: "from@example.com", 87 | to: []string{"to@example.com"}, 88 | content: "From: from@example.com\r\n" + 89 | "To: to@example.com\r\n" + 90 | "Subject: =?ISO-8859-1?b?Q2Fmw6k=?=\r\n" + 91 | "Content-Type: text/html; charset=ISO-8859-1\r\n" + 92 | "Content-Transfer-Encoding: base64\r\n" + 93 | "\r\n" + 94 | "wqFIb2xhLCBzZcOxb3Ih", 95 | } 96 | 97 | testMessage(t, m, 0, want) 98 | } 99 | 100 | func TestUnencodedMessage(t *testing.T) { 101 | m := NewMessage(SetEncoding(Unencoded)) 102 | m.SetHeaders(map[string][]string{ 103 | "From": {"from@example.com"}, 104 | "To": {"to@example.com"}, 105 | "Subject": {"Café"}, 106 | }) 107 | m.SetBody("text/html", "¡Hola, señor!") 108 | 109 | want := &message{ 110 | from: "from@example.com", 111 | to: []string{"to@example.com"}, 112 | content: "From: from@example.com\r\n" + 113 | "To: to@example.com\r\n" + 114 | "Subject: =?UTF-8?q?Caf=C3=A9?=\r\n" + 115 | "Content-Type: text/html; charset=UTF-8\r\n" + 116 | "Content-Transfer-Encoding: 8bit\r\n" + 117 | "\r\n" + 118 | "¡Hola, señor!", 119 | } 120 | 121 | testMessage(t, m, 0, want) 122 | } 123 | 124 | func TestRecipients(t *testing.T) { 125 | m := NewMessage() 126 | m.SetHeaders(map[string][]string{ 127 | "From": {"from@example.com"}, 128 | "To": {"to@example.com"}, 129 | "Cc": {"cc@example.com"}, 130 | "Bcc": {"bcc1@example.com", "bcc2@example.com"}, 131 | "Subject": {"Hello!"}, 132 | }) 133 | m.SetBody("text/plain", "Test message") 134 | 135 | want := &message{ 136 | from: "from@example.com", 137 | to: []string{"to@example.com", "cc@example.com", "bcc1@example.com", "bcc2@example.com"}, 138 | content: "From: from@example.com\r\n" + 139 | "To: to@example.com\r\n" + 140 | "Cc: cc@example.com\r\n" + 141 | "Subject: Hello!\r\n" + 142 | "Content-Type: text/plain; charset=UTF-8\r\n" + 143 | "Content-Transfer-Encoding: quoted-printable\r\n" + 144 | "\r\n" + 145 | "Test message", 146 | } 147 | 148 | testMessage(t, m, 0, want) 149 | } 150 | 151 | func TestAlternative(t *testing.T) { 152 | m := NewMessage() 153 | m.SetHeader("From", "from@example.com") 154 | m.SetHeader("To", "to@example.com") 155 | m.SetBody("text/plain", "¡Hola, señor!") 156 | m.AddAlternative("text/html", "¡Hola, señor!") 157 | 158 | want := &message{ 159 | from: "from@example.com", 160 | to: []string{"to@example.com"}, 161 | content: "From: from@example.com\r\n" + 162 | "To: to@example.com\r\n" + 163 | "Content-Type: multipart/alternative;\r\n" + 164 | " boundary=_BOUNDARY_1_\r\n" + 165 | "\r\n" + 166 | "--_BOUNDARY_1_\r\n" + 167 | "Content-Type: text/plain; charset=UTF-8\r\n" + 168 | "Content-Transfer-Encoding: quoted-printable\r\n" + 169 | "\r\n" + 170 | "=C2=A1Hola, se=C3=B1or!\r\n" + 171 | "--_BOUNDARY_1_\r\n" + 172 | "Content-Type: text/html; charset=UTF-8\r\n" + 173 | "Content-Transfer-Encoding: quoted-printable\r\n" + 174 | "\r\n" + 175 | "=C2=A1Hola, se=C3=B1or!\r\n" + 176 | "--_BOUNDARY_1_--\r\n", 177 | } 178 | 179 | testMessage(t, m, 1, want) 180 | } 181 | 182 | func TestPartSetting(t *testing.T) { 183 | m := NewMessage() 184 | m.SetHeader("From", "from@example.com") 185 | m.SetHeader("To", "to@example.com") 186 | m.SetBody("text/plain; format=flowed", "¡Hola, señor!", SetPartEncoding(Unencoded)) 187 | m.AddAlternative("text/html", "¡Hola, señor!") 188 | 189 | want := &message{ 190 | from: "from@example.com", 191 | to: []string{"to@example.com"}, 192 | content: "From: from@example.com\r\n" + 193 | "To: to@example.com\r\n" + 194 | "Content-Type: multipart/alternative;\r\n" + 195 | " boundary=_BOUNDARY_1_\r\n" + 196 | "\r\n" + 197 | "--_BOUNDARY_1_\r\n" + 198 | "Content-Type: text/plain; format=flowed; charset=UTF-8\r\n" + 199 | "Content-Transfer-Encoding: 8bit\r\n" + 200 | "\r\n" + 201 | "¡Hola, señor!\r\n" + 202 | "--_BOUNDARY_1_\r\n" + 203 | "Content-Type: text/html; charset=UTF-8\r\n" + 204 | "Content-Transfer-Encoding: quoted-printable\r\n" + 205 | "\r\n" + 206 | "=C2=A1Hola, se=C3=B1or!\r\n" + 207 | "--_BOUNDARY_1_--\r\n", 208 | } 209 | 210 | testMessage(t, m, 1, want) 211 | } 212 | 213 | func TestPartSettingWithCustomBoundary(t *testing.T) { 214 | m := NewMessage() 215 | m.SetBoundary("lalalaDaiMne3Ryblya") 216 | m.SetHeader("From", "from@example.com") 217 | m.SetHeader("To", "to@example.com") 218 | m.SetBody("text/plain; format=flowed", "¡Hola, señor!", SetPartEncoding(Unencoded)) 219 | m.AddAlternative("text/html", "¡Hola, señor!") 220 | 221 | want := &message{ 222 | from: "from@example.com", 223 | to: []string{"to@example.com"}, 224 | content: "From: from@example.com\r\n" + 225 | "To: to@example.com\r\n" + 226 | "Content-Type: multipart/alternative;\r\n" + 227 | " boundary=lalalaDaiMne3Ryblya\r\n" + 228 | "\r\n" + 229 | "--lalalaDaiMne3Ryblya\r\n" + 230 | "Content-Type: text/plain; format=flowed; charset=UTF-8\r\n" + 231 | "Content-Transfer-Encoding: 8bit\r\n" + 232 | "\r\n" + 233 | "¡Hola, señor!\r\n" + 234 | "--lalalaDaiMne3Ryblya\r\n" + 235 | "Content-Type: text/html; charset=UTF-8\r\n" + 236 | "Content-Transfer-Encoding: quoted-printable\r\n" + 237 | "\r\n" + 238 | "=C2=A1Hola, se=C3=B1or!\r\n" + 239 | "--lalalaDaiMne3Ryblya--\r\n", 240 | } 241 | 242 | testMessage(t, m, 1, want) 243 | } 244 | 245 | func TestBodyWriter(t *testing.T) { 246 | m := NewMessage() 247 | m.SetHeader("From", "from@example.com") 248 | m.SetHeader("To", "to@example.com") 249 | m.SetBodyWriter("text/plain", func(w io.Writer) error { 250 | _, err := w.Write([]byte("Test message")) 251 | return err 252 | }) 253 | m.AddAlternativeWriter("text/html", func(w io.Writer) error { 254 | _, err := w.Write([]byte("Test HTML")) 255 | return err 256 | }) 257 | 258 | want := &message{ 259 | from: "from@example.com", 260 | to: []string{"to@example.com"}, 261 | content: "From: from@example.com\r\n" + 262 | "To: to@example.com\r\n" + 263 | "Content-Type: multipart/alternative;\r\n" + 264 | " boundary=_BOUNDARY_1_\r\n" + 265 | "\r\n" + 266 | "--_BOUNDARY_1_\r\n" + 267 | "Content-Type: text/plain; charset=UTF-8\r\n" + 268 | "Content-Transfer-Encoding: quoted-printable\r\n" + 269 | "\r\n" + 270 | "Test message\r\n" + 271 | "--_BOUNDARY_1_\r\n" + 272 | "Content-Type: text/html; charset=UTF-8\r\n" + 273 | "Content-Transfer-Encoding: quoted-printable\r\n" + 274 | "\r\n" + 275 | "Test HTML\r\n" + 276 | "--_BOUNDARY_1_--\r\n", 277 | } 278 | 279 | testMessage(t, m, 1, want) 280 | } 281 | 282 | func TestAttachmentReader(t *testing.T) { 283 | m := NewMessage() 284 | m.SetHeader("From", "from@example.com") 285 | m.SetHeader("To", "to@example.com") 286 | 287 | var b bytes.Buffer 288 | b.Write([]byte("Test file")) 289 | m.AttachReader("file.txt", &b) 290 | 291 | want := &message{ 292 | from: "from@example.com", 293 | to: []string{"to@example.com"}, 294 | content: "From: from@example.com\r\n" + 295 | "To: to@example.com\r\n" + 296 | "Content-Type: text/plain; charset=utf-8; name=\"file.txt\"\r\n" + 297 | "Content-Disposition: attachment; filename=\"file.txt\"\r\n" + 298 | "Content-Transfer-Encoding: base64\r\n" + 299 | "\r\n" + 300 | base64.StdEncoding.EncodeToString([]byte("Test file")), 301 | } 302 | 303 | testMessage(t, m, 0, want) 304 | } 305 | 306 | func TestAttachmentOnly(t *testing.T) { 307 | m := NewMessage() 308 | m.SetHeader("From", "from@example.com") 309 | m.SetHeader("To", "to@example.com") 310 | m.Attach(mockCopyFile("/tmp/test.pdf")) 311 | 312 | want := &message{ 313 | from: "from@example.com", 314 | to: []string{"to@example.com"}, 315 | content: "From: from@example.com\r\n" + 316 | "To: to@example.com\r\n" + 317 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 318 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 319 | "Content-Transfer-Encoding: base64\r\n" + 320 | "\r\n" + 321 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")), 322 | } 323 | 324 | testMessage(t, m, 0, want) 325 | } 326 | 327 | func TestAttachment(t *testing.T) { 328 | m := NewMessage() 329 | m.SetHeader("From", "from@example.com") 330 | m.SetHeader("To", "to@example.com") 331 | m.SetBody("text/plain", "Test") 332 | m.Attach(mockCopyFile("/tmp/test.pdf")) 333 | 334 | want := &message{ 335 | from: "from@example.com", 336 | to: []string{"to@example.com"}, 337 | content: "From: from@example.com\r\n" + 338 | "To: to@example.com\r\n" + 339 | "Content-Type: multipart/mixed;\r\n" + 340 | " boundary=_BOUNDARY_1_\r\n" + 341 | "\r\n" + 342 | "--_BOUNDARY_1_\r\n" + 343 | "Content-Type: text/plain; charset=UTF-8\r\n" + 344 | "Content-Transfer-Encoding: quoted-printable\r\n" + 345 | "\r\n" + 346 | "Test\r\n" + 347 | "--_BOUNDARY_1_\r\n" + 348 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 349 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 350 | "Content-Transfer-Encoding: base64\r\n" + 351 | "\r\n" + 352 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 353 | "--_BOUNDARY_1_--\r\n", 354 | } 355 | 356 | testMessage(t, m, 1, want) 357 | } 358 | 359 | func TestRename(t *testing.T) { 360 | m := NewMessage() 361 | m.SetHeader("From", "from@example.com") 362 | m.SetHeader("To", "to@example.com") 363 | m.SetBody("text/plain", "Test") 364 | name, copy := mockCopyFile("/tmp/test.pdf") 365 | rename := Rename("another.pdf") 366 | m.Attach(name, copy, rename) 367 | 368 | want := &message{ 369 | from: "from@example.com", 370 | to: []string{"to@example.com"}, 371 | content: "From: from@example.com\r\n" + 372 | "To: to@example.com\r\n" + 373 | "Content-Type: multipart/mixed;\r\n" + 374 | " boundary=_BOUNDARY_1_\r\n" + 375 | "\r\n" + 376 | "--_BOUNDARY_1_\r\n" + 377 | "Content-Type: text/plain; charset=UTF-8\r\n" + 378 | "Content-Transfer-Encoding: quoted-printable\r\n" + 379 | "\r\n" + 380 | "Test\r\n" + 381 | "--_BOUNDARY_1_\r\n" + 382 | "Content-Type: application/pdf; name=\"another.pdf\"\r\n" + 383 | "Content-Disposition: attachment; filename=\"another.pdf\"\r\n" + 384 | "Content-Transfer-Encoding: base64\r\n" + 385 | "\r\n" + 386 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 387 | "--_BOUNDARY_1_--\r\n", 388 | } 389 | 390 | testMessage(t, m, 1, want) 391 | } 392 | 393 | func TestAttachmentsOnly(t *testing.T) { 394 | m := NewMessage() 395 | m.SetHeader("From", "from@example.com") 396 | m.SetHeader("To", "to@example.com") 397 | m.Attach(mockCopyFile("/tmp/test.pdf")) 398 | m.Attach(mockCopyFile("/tmp/test.zip")) 399 | 400 | want := &message{ 401 | from: "from@example.com", 402 | to: []string{"to@example.com"}, 403 | content: "From: from@example.com\r\n" + 404 | "To: to@example.com\r\n" + 405 | "Content-Type: multipart/mixed;\r\n" + 406 | " boundary=_BOUNDARY_1_\r\n" + 407 | "\r\n" + 408 | "--_BOUNDARY_1_\r\n" + 409 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 410 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 411 | "Content-Transfer-Encoding: base64\r\n" + 412 | "\r\n" + 413 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 414 | "--_BOUNDARY_1_\r\n" + 415 | "Content-Type: application/zip; name=\"test.zip\"\r\n" + 416 | "Content-Disposition: attachment; filename=\"test.zip\"\r\n" + 417 | "Content-Transfer-Encoding: base64\r\n" + 418 | "\r\n" + 419 | base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" + 420 | "--_BOUNDARY_1_--\r\n", 421 | } 422 | 423 | testMessage(t, m, 1, want) 424 | } 425 | 426 | func TestAttachments(t *testing.T) { 427 | m := NewMessage() 428 | m.SetHeader("From", "from@example.com") 429 | m.SetHeader("To", "to@example.com") 430 | m.SetBody("text/plain", "Test") 431 | m.Attach(mockCopyFile("/tmp/test.pdf")) 432 | m.Attach(mockCopyFile("/tmp/test.zip")) 433 | 434 | want := &message{ 435 | from: "from@example.com", 436 | to: []string{"to@example.com"}, 437 | content: "From: from@example.com\r\n" + 438 | "To: to@example.com\r\n" + 439 | "Content-Type: multipart/mixed;\r\n" + 440 | " boundary=_BOUNDARY_1_\r\n" + 441 | "\r\n" + 442 | "--_BOUNDARY_1_\r\n" + 443 | "Content-Type: text/plain; charset=UTF-8\r\n" + 444 | "Content-Transfer-Encoding: quoted-printable\r\n" + 445 | "\r\n" + 446 | "Test\r\n" + 447 | "--_BOUNDARY_1_\r\n" + 448 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 449 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 450 | "Content-Transfer-Encoding: base64\r\n" + 451 | "\r\n" + 452 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 453 | "--_BOUNDARY_1_\r\n" + 454 | "Content-Type: application/zip; name=\"test.zip\"\r\n" + 455 | "Content-Disposition: attachment; filename=\"test.zip\"\r\n" + 456 | "Content-Transfer-Encoding: base64\r\n" + 457 | "\r\n" + 458 | base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" + 459 | "--_BOUNDARY_1_--\r\n", 460 | } 461 | 462 | testMessage(t, m, 1, want) 463 | } 464 | 465 | func TestEmbeddedReader(t *testing.T) { 466 | m := NewMessage() 467 | m.SetHeader("From", "from@example.com") 468 | m.SetHeader("To", "to@example.com") 469 | 470 | var b bytes.Buffer 471 | b.Write([]byte("Test file")) 472 | m.EmbedReader("file.txt", &b) 473 | 474 | want := &message{ 475 | from: "from@example.com", 476 | to: []string{"to@example.com"}, 477 | content: "From: from@example.com\r\n" + 478 | "To: to@example.com\r\n" + 479 | "Content-Type: text/plain; charset=utf-8; name=\"file.txt\"\r\n" + 480 | "Content-Transfer-Encoding: base64\r\n" + 481 | "Content-Disposition: inline; filename=\"file.txt\"\r\n" + 482 | "Content-ID: \r\n" + 483 | "\r\n" + 484 | base64.StdEncoding.EncodeToString([]byte("Test file")), 485 | } 486 | 487 | testMessage(t, m, 0, want) 488 | } 489 | 490 | func TestEmbedded(t *testing.T) { 491 | m := NewMessage() 492 | m.SetHeader("From", "from@example.com") 493 | m.SetHeader("To", "to@example.com") 494 | m.Embed(mockCopyFileWithHeader(m, "image1.jpg", map[string][]string{"Content-ID": {""}})) 495 | m.Embed(mockCopyFile("image2.jpg")) 496 | m.SetBody("text/plain", "Test") 497 | 498 | want := &message{ 499 | from: "from@example.com", 500 | to: []string{"to@example.com"}, 501 | content: "From: from@example.com\r\n" + 502 | "To: to@example.com\r\n" + 503 | "Content-Type: multipart/related;\r\n" + 504 | " boundary=_BOUNDARY_1_\r\n" + 505 | "\r\n" + 506 | "--_BOUNDARY_1_\r\n" + 507 | "Content-Type: text/plain; charset=UTF-8\r\n" + 508 | "Content-Transfer-Encoding: quoted-printable\r\n" + 509 | "\r\n" + 510 | "Test\r\n" + 511 | "--_BOUNDARY_1_\r\n" + 512 | "Content-Type: image/jpeg; name=\"image1.jpg\"\r\n" + 513 | "Content-Disposition: inline; filename=\"image1.jpg\"\r\n" + 514 | "Content-ID: \r\n" + 515 | "Content-Transfer-Encoding: base64\r\n" + 516 | "\r\n" + 517 | base64.StdEncoding.EncodeToString([]byte("Content of image1.jpg")) + "\r\n" + 518 | "--_BOUNDARY_1_\r\n" + 519 | "Content-Type: image/jpeg; name=\"image2.jpg\"\r\n" + 520 | "Content-Disposition: inline; filename=\"image2.jpg\"\r\n" + 521 | "Content-ID: \r\n" + 522 | "Content-Transfer-Encoding: base64\r\n" + 523 | "\r\n" + 524 | base64.StdEncoding.EncodeToString([]byte("Content of image2.jpg")) + "\r\n" + 525 | "--_BOUNDARY_1_--\r\n", 526 | } 527 | 528 | testMessage(t, m, 1, want) 529 | } 530 | 531 | func TestFullMessage(t *testing.T) { 532 | m := NewMessage() 533 | m.SetHeader("From", "from@example.com") 534 | m.SetHeader("To", "to@example.com") 535 | m.SetBody("text/plain", "¡Hola, señor!") 536 | m.AddAlternative("text/html", "¡Hola, señor!") 537 | m.Attach(mockCopyFile("test.pdf")) 538 | m.Embed(mockCopyFile("image.jpg")) 539 | 540 | want := &message{ 541 | from: "from@example.com", 542 | to: []string{"to@example.com"}, 543 | content: "From: from@example.com\r\n" + 544 | "To: to@example.com\r\n" + 545 | "Content-Type: multipart/mixed;\r\n" + 546 | " boundary=_BOUNDARY_1_\r\n" + 547 | "\r\n" + 548 | "--_BOUNDARY_1_\r\n" + 549 | "Content-Type: multipart/related;\r\n" + 550 | " boundary=_BOUNDARY_2_\r\n" + 551 | "\r\n" + 552 | "--_BOUNDARY_2_\r\n" + 553 | "Content-Type: multipart/alternative;\r\n" + 554 | " boundary=_BOUNDARY_3_\r\n" + 555 | "\r\n" + 556 | "--_BOUNDARY_3_\r\n" + 557 | "Content-Type: text/plain; charset=UTF-8\r\n" + 558 | "Content-Transfer-Encoding: quoted-printable\r\n" + 559 | "\r\n" + 560 | "=C2=A1Hola, se=C3=B1or!\r\n" + 561 | "--_BOUNDARY_3_\r\n" + 562 | "Content-Type: text/html; charset=UTF-8\r\n" + 563 | "Content-Transfer-Encoding: quoted-printable\r\n" + 564 | "\r\n" + 565 | "=C2=A1Hola, se=C3=B1or!\r\n" + 566 | "--_BOUNDARY_3_--\r\n" + 567 | "\r\n" + 568 | "--_BOUNDARY_2_\r\n" + 569 | "Content-Type: image/jpeg; name=\"image.jpg\"\r\n" + 570 | "Content-Disposition: inline; filename=\"image.jpg\"\r\n" + 571 | "Content-ID: \r\n" + 572 | "Content-Transfer-Encoding: base64\r\n" + 573 | "\r\n" + 574 | base64.StdEncoding.EncodeToString([]byte("Content of image.jpg")) + "\r\n" + 575 | "--_BOUNDARY_2_--\r\n" + 576 | "\r\n" + 577 | "--_BOUNDARY_1_\r\n" + 578 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 579 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 580 | "Content-Transfer-Encoding: base64\r\n" + 581 | "\r\n" + 582 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 583 | "--_BOUNDARY_1_--\r\n", 584 | } 585 | 586 | testMessage(t, m, 3, want) 587 | 588 | want = &message{ 589 | from: "from@example.com", 590 | to: []string{"to@example.com"}, 591 | content: "From: from@example.com\r\n" + 592 | "To: to@example.com\r\n" + 593 | "Content-Type: text/plain; charset=UTF-8\r\n" + 594 | "Content-Transfer-Encoding: quoted-printable\r\n" + 595 | "\r\n" + 596 | "Test reset", 597 | } 598 | m.Reset() 599 | m.SetHeader("From", "from@example.com") 600 | m.SetHeader("To", "to@example.com") 601 | m.SetBody("text/plain", "Test reset") 602 | testMessage(t, m, 0, want) 603 | } 604 | 605 | func TestQpLineLength(t *testing.T) { 606 | m := NewMessage() 607 | m.SetHeader("From", "from@example.com") 608 | m.SetHeader("To", "to@example.com") 609 | m.SetBody("text/plain", 610 | strings.Repeat("0", 76)+"\r\n"+ 611 | strings.Repeat("0", 75)+"à\r\n"+ 612 | strings.Repeat("0", 74)+"à\r\n"+ 613 | strings.Repeat("0", 73)+"à\r\n"+ 614 | strings.Repeat("0", 72)+"à\r\n"+ 615 | strings.Repeat("0", 75)+"\r\n"+ 616 | strings.Repeat("0", 76)+"\n") 617 | 618 | want := &message{ 619 | from: "from@example.com", 620 | to: []string{"to@example.com"}, 621 | content: "From: from@example.com\r\n" + 622 | "To: to@example.com\r\n" + 623 | "Content-Type: text/plain; charset=UTF-8\r\n" + 624 | "Content-Transfer-Encoding: quoted-printable\r\n" + 625 | "\r\n" + 626 | strings.Repeat("0", 75) + "=\r\n0\r\n" + 627 | strings.Repeat("0", 75) + "=\r\n=C3=A0\r\n" + 628 | strings.Repeat("0", 74) + "=\r\n=C3=A0\r\n" + 629 | strings.Repeat("0", 73) + "=\r\n=C3=A0\r\n" + 630 | strings.Repeat("0", 72) + "=C3=\r\n=A0\r\n" + 631 | strings.Repeat("0", 75) + "\r\n" + 632 | strings.Repeat("0", 75) + "=\r\n0\r\n", 633 | } 634 | 635 | testMessage(t, m, 0, want) 636 | } 637 | 638 | func TestBase64LineLength(t *testing.T) { 639 | m := NewMessage(SetCharset("UTF-8"), SetEncoding(Base64)) 640 | m.SetHeader("From", "from@example.com") 641 | m.SetHeader("To", "to@example.com") 642 | m.SetBody("text/plain", strings.Repeat("0", 58)) 643 | 644 | want := &message{ 645 | from: "from@example.com", 646 | to: []string{"to@example.com"}, 647 | content: "From: from@example.com\r\n" + 648 | "To: to@example.com\r\n" + 649 | "Content-Type: text/plain; charset=UTF-8\r\n" + 650 | "Content-Transfer-Encoding: base64\r\n" + 651 | "\r\n" + 652 | strings.Repeat("MDAw", 19) + "\r\nMA==", 653 | } 654 | 655 | testMessage(t, m, 0, want) 656 | } 657 | 658 | func TestEmptyName(t *testing.T) { 659 | m := NewMessage() 660 | m.SetAddressHeader("From", "from@example.com", "") 661 | 662 | want := &message{ 663 | from: "from@example.com", 664 | content: "From: from@example.com\r\n", 665 | } 666 | 667 | testMessage(t, m, 0, want) 668 | } 669 | 670 | func TestEmptyHeader(t *testing.T) { 671 | m := NewMessage() 672 | m.SetHeaders(map[string][]string{ 673 | "From": {"from@example.com"}, 674 | "X-Empty": nil, 675 | }) 676 | 677 | want := &message{ 678 | from: "from@example.com", 679 | content: "From: from@example.com\r\n" + 680 | "X-Empty:\r\n", 681 | } 682 | 683 | testMessage(t, m, 0, want) 684 | } 685 | 686 | func testMessage(t *testing.T, m *Message, bCount int, want *message) { 687 | err := Send(context.Background(), stubSendMail(t, bCount, want), m) 688 | if err != nil { 689 | t.Error(err) 690 | } 691 | } 692 | 693 | func stubSendMail(t *testing.T, bCount int, want *message) SendFunc { 694 | return func(ctx context.Context, from string, to []string, m io.WriterTo) error { 695 | if from != want.from { 696 | t.Fatalf("Invalid from, got %q, want %q", from, want.from) 697 | } 698 | 699 | if len(to) != len(want.to) { 700 | t.Fatalf("Invalid recipient count, \ngot %d: %q\nwant %d: %q", 701 | len(to), to, 702 | len(want.to), want.to, 703 | ) 704 | } 705 | for i := range want.to { 706 | if to[i] != want.to[i] { 707 | t.Fatalf("Invalid recipient, got %q, want %q", 708 | to[i], want.to[i], 709 | ) 710 | } 711 | } 712 | 713 | buf := new(bytes.Buffer) 714 | _, err := m.WriteTo(buf) 715 | if err != nil { 716 | t.Error(err) 717 | } 718 | got := buf.String() 719 | wantMsg := string("MIME-Version: 1.0\r\n" + 720 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 721 | want.content) 722 | if bCount > 0 { 723 | boundaries := getBoundaries(t, bCount, got) 724 | for i, b := range boundaries { 725 | wantMsg = strings.Replace(wantMsg, "_BOUNDARY_"+strconv.Itoa(i+1)+"_", b, -1) 726 | } 727 | } 728 | 729 | compareBodies(t, got, wantMsg) 730 | 731 | return nil 732 | } 733 | } 734 | 735 | func compareBodies(t *testing.T, got, want string) { 736 | // We cannot do a simple comparison since the ordering of headers' fields 737 | // is random. 738 | gotLines := strings.Split(got, "\r\n") 739 | wantLines := strings.Split(want, "\r\n") 740 | 741 | // We only test for too many lines, missing lines are tested after 742 | if len(gotLines) > len(wantLines) { 743 | t.Fatalf("Message has too many lines, \ngot %d:\n%s\nwant %d:\n%s", len(gotLines), got, len(wantLines), want) 744 | } 745 | 746 | isInHeader := true 747 | headerStart := 0 748 | for i, line := range wantLines { 749 | if line == gotLines[i] { 750 | if line == "" { 751 | isInHeader = false 752 | } else if !isInHeader && len(line) > 2 && line[:2] == "--" { 753 | isInHeader = true 754 | headerStart = i + 1 755 | } 756 | continue 757 | } 758 | 759 | if !isInHeader { 760 | missingLine(t, line, got, want) 761 | } 762 | 763 | isMissing := true 764 | for j := headerStart; j < len(gotLines); j++ { 765 | if gotLines[j] == "" { 766 | break 767 | } 768 | if gotLines[j] == line { 769 | isMissing = false 770 | break 771 | } 772 | } 773 | if isMissing { 774 | missingLine(t, line, got, want) 775 | } 776 | } 777 | } 778 | 779 | func missingLine(t *testing.T, line, got, want string) { 780 | t.Fatalf("Missing line %q\ngot:\n%s\nwant:\n%s", line, got, want) 781 | } 782 | 783 | func getBoundaries(t *testing.T, count int, m string) []string { 784 | if matches := boundaryRegExp.FindAllStringSubmatch(m, count); matches != nil { 785 | boundaries := make([]string, count) 786 | for i, match := range matches { 787 | boundaries[i] = match[1] 788 | } 789 | return boundaries 790 | } 791 | 792 | t.Fatal("Boundary not found in body") 793 | return []string{""} 794 | } 795 | 796 | var boundaryRegExp = regexp.MustCompile("boundary=(\\w+)") 797 | 798 | func mockCopyFile(name string) (string, FileSetting) { 799 | return name, SetCopyFunc(func(w io.Writer) error { 800 | _, err := w.Write([]byte("Content of " + filepath.Base(name))) 801 | return err 802 | }) 803 | } 804 | 805 | func mockCopyFileWithHeader(m *Message, name string, h map[string][]string) (string, FileSetting, FileSetting) { 806 | name, f := mockCopyFile(name) 807 | return name, f, SetHeader(h) 808 | } 809 | 810 | func BenchmarkFull(b *testing.B) { 811 | discardFunc := SendFunc(func(ctx context.Context, from string, to []string, m io.WriterTo) error { 812 | _, err := m.WriteTo(ioutil.Discard) 813 | return err 814 | }) 815 | 816 | m := NewMessage() 817 | b.ResetTimer() 818 | for n := 0; n < b.N; n++ { 819 | m.SetAddressHeader("From", "from@example.com", "Señor From") 820 | m.SetHeaders(map[string][]string{ 821 | "To": {"to@example.com"}, 822 | "Cc": {"cc@example.com"}, 823 | "Bcc": {"bcc1@example.com", "bcc2@example.com"}, 824 | "Subject": {"¡Hola, señor!"}, 825 | }) 826 | m.SetBody("text/plain", "¡Hola, señor!") 827 | m.AddAlternative("text/html", "

¡Hola, señor!

") 828 | m.Attach(mockCopyFile("benchmark.txt")) 829 | m.Embed(mockCopyFile("benchmark.jpg")) 830 | 831 | if err := Send(context.Background(), discardFunc, m); err != nil { 832 | panic(err) 833 | } 834 | m.Reset() 835 | } 836 | } 837 | -------------------------------------------------------------------------------- /mime.go: -------------------------------------------------------------------------------- 1 | // +build go1.5 2 | 3 | package mail 4 | 5 | import ( 6 | "mime" 7 | "mime/quotedprintable" 8 | "strings" 9 | ) 10 | 11 | var newQPWriter = quotedprintable.NewWriter 12 | 13 | type mimeEncoder struct { 14 | mime.WordEncoder 15 | } 16 | 17 | var ( 18 | bEncoding = mimeEncoder{mime.BEncoding} 19 | qEncoding = mimeEncoder{mime.QEncoding} 20 | lastIndexByte = strings.LastIndexByte 21 | ) 22 | -------------------------------------------------------------------------------- /mime_go14.go: -------------------------------------------------------------------------------- 1 | // +build !go1.5 2 | 3 | package mail 4 | 5 | import "gopkg.in/alexcesaro/quotedprintable.v3" 6 | 7 | var newQPWriter = quotedprintable.NewWriter 8 | 9 | type mimeEncoder struct { 10 | quotedprintable.WordEncoder 11 | } 12 | 13 | var ( 14 | bEncoding = mimeEncoder{quotedprintable.BEncoding} 15 | qEncoding = mimeEncoder{quotedprintable.QEncoding} 16 | lastIndexByte = func(s string, c byte) int { 17 | for i := len(s) - 1; i >= 0; i-- { 18 | 19 | if s[i] == c { 20 | return i 21 | } 22 | } 23 | return -1 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /send.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "context" 5 | "io" 6 | stdmail "net/mail" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // Sender is the interface that wraps the Send method. 12 | // 13 | // Send sends an email to the given addresses. 14 | type Sender interface { 15 | Send(ctx context.Context, from string, to []string, msg io.WriterTo) error 16 | } 17 | 18 | // SendCloser is the interface that groups the Send and Close methods. 19 | type SendCloser interface { 20 | Sender 21 | Close() error 22 | } 23 | 24 | // A SendFunc is a function that sends emails to the given addresses. 25 | // 26 | // The SendFunc type is an adapter to allow the use of ordinary functions as 27 | // email senders. If f is a function with the appropriate signature, SendFunc(f) 28 | // is a Sender object that calls f. 29 | type SendFunc func(ctx context.Context, from string, to []string, msg io.WriterTo) error 30 | 31 | // Send calls f(from, to, msg). 32 | func (f SendFunc) Send(ctx context.Context, from string, to []string, msg io.WriterTo) error { 33 | return f(ctx, from, to, msg) 34 | } 35 | 36 | // Send sends emails using the given Sender. 37 | func Send(ctx context.Context, s Sender, msg ...*Message) error { 38 | for i, m := range msg { 39 | if err := send(ctx, s, m); err != nil { 40 | return &SendError{Cause: err, Index: uint(i)} 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func send(ctx context.Context, s Sender, m *Message) error { 48 | from, err := m.getFrom() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | to, err := m.getRecipients() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if err := s.Send(ctx, from, to, m); err != nil { 59 | return err 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (m *Message) getFrom() (string, error) { 66 | from := m.header["Sender"] 67 | if len(from) == 0 { 68 | from = m.header["From"] 69 | if len(from) == 0 { 70 | return "", errors.New(`gomail: invalid message, "From" field is absent`) 71 | } 72 | } 73 | 74 | return parseAddress(from[0]) 75 | } 76 | 77 | func (m *Message) getRecipients() ([]string, error) { 78 | n := 0 79 | for _, field := range []string{"To", "Cc", "Bcc"} { 80 | if addresses, ok := m.header[field]; ok { 81 | n += len(addresses) 82 | } 83 | } 84 | list := make([]string, 0, n) 85 | 86 | for _, field := range []string{"To", "Cc", "Bcc"} { 87 | if addresses, ok := m.header[field]; ok { 88 | for _, a := range addresses { 89 | addr, err := parseAddress(a) 90 | if err != nil { 91 | return nil, err 92 | } 93 | list = addAddress(list, addr) 94 | } 95 | } 96 | } 97 | 98 | return list, nil 99 | } 100 | 101 | func addAddress(list []string, addr string) []string { 102 | for _, a := range list { 103 | if addr == a { 104 | return list 105 | } 106 | } 107 | 108 | return append(list, addr) 109 | } 110 | 111 | func parseAddress(field string) (string, error) { 112 | addr, err := stdmail.ParseAddress(field) 113 | if err != nil { 114 | return "", errors.Errorf("gomail: invalid address %q: %v", field, err) 115 | } 116 | return addr.Address, nil 117 | } 118 | -------------------------------------------------------------------------------- /send_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | const ( 14 | testTo1 = "to1@example.com" 15 | testTo2 = "to2@example.com" 16 | testFrom = "from@example.com" 17 | testBody = "Test message" 18 | testMsg = "To: " + testTo1 + ", " + testTo2 + "\r\n" + 19 | "From: " + testFrom + "\r\n" + 20 | "MIME-Version: 1.0\r\n" + 21 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 22 | "Content-Type: text/plain; charset=UTF-8\r\n" + 23 | "Content-Transfer-Encoding: quoted-printable\r\n" + 24 | "\r\n" + 25 | testBody 26 | ) 27 | 28 | type mockSender SendFunc 29 | 30 | func (s mockSender) Send(ctx context.Context, from string, to []string, msg io.WriterTo) error { 31 | return s(ctx, from, to, msg) 32 | } 33 | 34 | type mockSendCloser struct { 35 | mockSender 36 | close func() error 37 | } 38 | 39 | func (s *mockSendCloser) Close() error { 40 | return s.close() 41 | } 42 | 43 | func TestSend(t *testing.T) { 44 | s := &mockSendCloser{ 45 | mockSender: stubSend(t, testFrom, []string{testTo1, testTo2}, testMsg), 46 | close: func() error { 47 | t.Error("Close() should not be called in Send()") 48 | return nil 49 | }, 50 | } 51 | if err := Send(context.Background(), s, getTestMessage()); err != nil { 52 | t.Errorf("Send(): %v", err) 53 | } 54 | } 55 | 56 | func TestSendError(t *testing.T) { 57 | s := &mockSendCloser{ 58 | mockSender: func(_ context.Context, _ string, _ []string, _ io.WriterTo) error { 59 | return errors.New("kaboom") 60 | }, 61 | } 62 | wantErr := "gomail: could not send email 1: kaboom" 63 | if err := Send(context.Background(), s, getTestMessage()); err == nil || err.Error() != wantErr { 64 | t.Errorf("expected Send() error, got %q, want %q", err, wantErr) 65 | } 66 | } 67 | 68 | func getTestMessage() *Message { 69 | m := NewMessage() 70 | m.SetHeader("From", testFrom) 71 | m.SetHeader("To", testTo1, testTo2) 72 | m.SetBody("text/plain", testBody) 73 | 74 | return m 75 | } 76 | 77 | func stubSend(t *testing.T, wantFrom string, wantTo []string, wantBody string) mockSender { 78 | return func(ctx context.Context, from string, to []string, msg io.WriterTo) error { 79 | if from != wantFrom { 80 | t.Errorf("invalid from, got %q, want %q", from, wantFrom) 81 | } 82 | if !reflect.DeepEqual(to, wantTo) { 83 | t.Errorf("invalid to, got %v, want %v", to, wantTo) 84 | } 85 | 86 | buf := new(bytes.Buffer) 87 | _, err := msg.WriteTo(buf) 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | compareBodies(t, buf.String(), wantBody) 92 | 93 | return nil 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /smtp.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/smtp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // A Dialer is a dialer to an SMTP server. 17 | type Dialer struct { 18 | // Host represents the host of the SMTP server. 19 | Host string 20 | // Port represents the port of the SMTP server. 21 | Port int 22 | // Username is the username to use to authenticate to the SMTP server. 23 | Username string 24 | // Password is the password to use to authenticate to the SMTP server. 25 | Password string 26 | // Auth represents the authentication mechanism used to authenticate to the 27 | // SMTP server. 28 | Auth smtp.Auth 29 | // SSL defines whether an SSL connection is used. It should be false in 30 | // most cases since the authentication mechanism should use the STARTTLS 31 | // extension instead. 32 | SSL bool 33 | // TLSConfig represents the TLS configuration used for the TLS (when the 34 | // STARTTLS extension is used) or SSL connection. 35 | TLSConfig *tls.Config 36 | // StartTLSPolicy represents the TLS security level required to 37 | // communicate with the SMTP server. 38 | // 39 | // This defaults to OpportunisticStartTLS for backwards compatibility, 40 | // but we recommend MandatoryStartTLS for all modern SMTP servers. 41 | // 42 | // This option has no effect if SSL is set to true. 43 | StartTLSPolicy StartTLSPolicy 44 | // LocalName is the hostname sent to the SMTP server with the HELO command. 45 | // By default, "localhost" is sent. 46 | LocalName string 47 | // Timeout to use for read/write operations. Defaults to 10 seconds, can 48 | // be set to 0 to disable timeouts. 49 | Timeout time.Duration 50 | // Whether we should retry mailing if the connection returned an error, 51 | // defaults to true. 52 | RetryFailure bool 53 | } 54 | 55 | // NewDialer returns a new SMTP Dialer. The given parameters are used to connect 56 | // to the SMTP server. 57 | func NewDialer(host string, port int, username, password string) *Dialer { 58 | return &Dialer{ 59 | Host: host, 60 | Port: port, 61 | Username: username, 62 | Password: password, 63 | SSL: port == 465, 64 | Timeout: 10 * time.Second, 65 | RetryFailure: true, 66 | } 67 | } 68 | 69 | // NewPlainDialer returns a new SMTP Dialer. The given parameters are used to 70 | // connect to the SMTP server. 71 | // 72 | // Deprecated: Use NewDialer instead. 73 | func NewPlainDialer(host string, port int, username, password string) *Dialer { 74 | return NewDialer(host, port, username, password) 75 | } 76 | 77 | // NetDialTimeout specifies the DialTimeout function to establish a connection 78 | // to the SMTP server. This can be used to override dialing in the case that a 79 | // proxy or other special behavior is needed. 80 | var NetDialTimeout = net.DialTimeout 81 | 82 | // Dial dials and authenticates to an SMTP server. The returned SendCloser 83 | // should be closed when done using it. 84 | func (d *Dialer) Dial(ctx context.Context) (SendCloser, error) { 85 | if d.Timeout == 0 { 86 | d.Timeout = time.Second * 10 87 | } 88 | 89 | nd := &net.Dialer{Timeout: d.Timeout} 90 | conn, err := nd.DialContext(ctx, "tcp", addr(d.Host, d.Port)) 91 | if err != nil { 92 | return nil, errors.WithStack(err) 93 | } 94 | 95 | deadline := time.Now().Add(d.Timeout) 96 | _ = conn.SetDeadline(deadline) 97 | 98 | if d.SSL { 99 | conn = tlsClient(conn, d.tlsConfig()) 100 | } 101 | 102 | c, err := smtpNewClient(conn, d.Host) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | if d.LocalName != "" { 108 | if err := c.Hello(d.LocalName); err != nil { 109 | return nil, err 110 | } 111 | } 112 | 113 | if !d.SSL && d.StartTLSPolicy != NoStartTLS { 114 | ok, _ := c.Extension("STARTTLS") 115 | if !ok && d.StartTLSPolicy == MandatoryStartTLS { 116 | err := StartTLSUnsupportedError{ 117 | Policy: d.StartTLSPolicy} 118 | return nil, err 119 | } 120 | 121 | if ok { 122 | if err := c.StartTLS(d.tlsConfig()); err != nil { 123 | c.Close() 124 | return nil, err 125 | } 126 | } 127 | } 128 | 129 | if d.Auth == nil && d.Username != "" { 130 | if ok, auths := c.Extension("AUTH"); ok { 131 | if strings.Contains(auths, "CRAM-MD5") { 132 | d.Auth = smtp.CRAMMD5Auth(d.Username, d.Password) 133 | } else if strings.Contains(auths, "LOGIN") && 134 | !strings.Contains(auths, "PLAIN") { 135 | d.Auth = &loginAuth{ 136 | username: d.Username, 137 | password: d.Password, 138 | host: d.Host, 139 | } 140 | } else { 141 | d.Auth = smtp.PlainAuth("", d.Username, d.Password, d.Host) 142 | } 143 | } 144 | } 145 | 146 | if d.Auth != nil { 147 | if err = c.Auth(d.Auth); err != nil { 148 | c.Close() 149 | return nil, err 150 | } 151 | } 152 | 153 | return &smtpSender{c, conn, d}, nil 154 | } 155 | 156 | func (d *Dialer) tlsConfig() *tls.Config { 157 | if d.TLSConfig == nil { 158 | return &tls.Config{ServerName: d.Host} 159 | } 160 | return d.TLSConfig 161 | } 162 | 163 | // StartTLSPolicy constants are valid values for Dialer.StartTLSPolicy. 164 | type StartTLSPolicy int 165 | 166 | const ( 167 | // OpportunisticStartTLS means that SMTP transactions are encrypted if 168 | // STARTTLS is supported by the SMTP server. Otherwise, messages are 169 | // sent in the clear. This is the default setting. 170 | OpportunisticStartTLS StartTLSPolicy = iota 171 | // MandatoryStartTLS means that SMTP transactions must be encrypted. 172 | // SMTP transactions are aborted unless STARTTLS is supported by the 173 | // SMTP server. 174 | MandatoryStartTLS 175 | // NoStartTLS means encryption is disabled and messages are sent in the 176 | // clear. 177 | NoStartTLS = -1 178 | ) 179 | 180 | func (policy *StartTLSPolicy) String() string { 181 | switch *policy { 182 | case OpportunisticStartTLS: 183 | return "OpportunisticStartTLS" 184 | case MandatoryStartTLS: 185 | return "MandatoryStartTLS" 186 | case NoStartTLS: 187 | return "NoStartTLS" 188 | default: 189 | return fmt.Sprintf("StartTLSPolicy:%v", *policy) 190 | } 191 | } 192 | 193 | // StartTLSUnsupportedError is returned by Dial when connecting to an SMTP 194 | // server that does not support STARTTLS. 195 | type StartTLSUnsupportedError struct { 196 | Policy StartTLSPolicy 197 | } 198 | 199 | func (e StartTLSUnsupportedError) Error() string { 200 | return "gomail: " + e.Policy.String() + " required, but " + 201 | "SMTP server does not support STARTTLS" 202 | } 203 | 204 | func addr(host string, port int) string { 205 | return fmt.Sprintf("%s:%d", host, port) 206 | } 207 | 208 | // DialAndSend opens a connection to the SMTP server, sends the given emails and 209 | // closes the connection. 210 | func (d *Dialer) DialAndSend(ctx context.Context, m ...*Message) error { 211 | s, err := d.Dial(ctx) 212 | if err != nil { 213 | return err 214 | } 215 | defer s.Close() 216 | 217 | return Send(ctx, s, m...) 218 | } 219 | 220 | type smtpSender struct { 221 | smtpClient 222 | conn net.Conn 223 | d *Dialer 224 | } 225 | 226 | func (c *smtpSender) retryError(err error) bool { 227 | if !c.d.RetryFailure { 228 | return false 229 | } 230 | 231 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 232 | return true 233 | } 234 | 235 | return err == io.EOF 236 | } 237 | 238 | func (c *smtpSender) Send(ctx context.Context, from string, to []string, msg io.WriterTo) error { 239 | if c.d.Timeout > 0 { 240 | c.conn.SetDeadline(time.Now().Add(c.d.Timeout)) 241 | } 242 | 243 | if err := c.Mail(from); err != nil { 244 | if c.retryError(err) { 245 | // This is probably due to a timeout, so reconnect and try again. 246 | sc, derr := c.d.Dial(ctx) 247 | if derr == nil { 248 | if s, ok := sc.(*smtpSender); ok { 249 | *c = *s 250 | return c.Send(ctx, from, to, msg) 251 | } 252 | } 253 | } 254 | 255 | return err 256 | } 257 | 258 | for _, addr := range to { 259 | if err := c.Rcpt(addr); err != nil { 260 | return err 261 | } 262 | } 263 | 264 | w, err := c.Data() 265 | if err != nil { 266 | return err 267 | } 268 | 269 | if _, err = msg.WriteTo(w); err != nil { 270 | w.Close() 271 | return err 272 | } 273 | 274 | return w.Close() 275 | } 276 | 277 | func (c *smtpSender) Close() error { 278 | return c.Quit() 279 | } 280 | 281 | // Stubbed out for tests. 282 | var ( 283 | tlsClient = tls.Client 284 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { 285 | return smtp.NewClient(conn, host) 286 | } 287 | ) 288 | 289 | type smtpClient interface { 290 | Hello(string) error 291 | Extension(string) (bool, string) 292 | StartTLS(*tls.Config) error 293 | Auth(smtp.Auth) error 294 | Mail(string) error 295 | Rcpt(string) error 296 | Data() (io.WriteCloser, error) 297 | Quit() error 298 | Close() error 299 | } 300 | -------------------------------------------------------------------------------- /smtp_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "io" 8 | "net" 9 | "net/smtp" 10 | "reflect" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | const ( 18 | testPort = 587 19 | testSSLPort = 465 20 | ) 21 | 22 | var ( 23 | testConn = &net.TCPConn{} 24 | testTLSConn = tls.Client(testConn, &tls.Config{InsecureSkipVerify: true}) 25 | testConfig = &tls.Config{InsecureSkipVerify: true} 26 | testAuth = smtp.PlainAuth("", testUser, testPwd, testHost) 27 | ) 28 | 29 | func TestDialer(t *testing.T) { 30 | d := NewDialer(testHost, testPort, "user", "pwd") 31 | testSendMail(t, d, []string{ 32 | "Extension STARTTLS", 33 | "StartTLS", 34 | "Extension AUTH", 35 | "Auth", 36 | "Mail " + testFrom, 37 | "Rcpt " + testTo1, 38 | "Rcpt " + testTo2, 39 | "Data", 40 | "Write message", 41 | "Close writer", 42 | "Quit", 43 | "Close", 44 | }) 45 | } 46 | 47 | func TestDialerSSL(t *testing.T) { 48 | d := NewDialer(testHost, testSSLPort, "user", "pwd") 49 | testSendMail(t, d, []string{ 50 | "Extension AUTH", 51 | "Auth", 52 | "Mail " + testFrom, 53 | "Rcpt " + testTo1, 54 | "Rcpt " + testTo2, 55 | "Data", 56 | "Write message", 57 | "Close writer", 58 | "Quit", 59 | "Close", 60 | }) 61 | } 62 | 63 | func TestDialerConfig(t *testing.T) { 64 | d := NewDialer(testHost, testPort, "user", "pwd") 65 | d.LocalName = "test" 66 | d.TLSConfig = testConfig 67 | testSendMail(t, d, []string{ 68 | "Hello test", 69 | "Extension STARTTLS", 70 | "StartTLS", 71 | "Extension AUTH", 72 | "Auth", 73 | "Mail " + testFrom, 74 | "Rcpt " + testTo1, 75 | "Rcpt " + testTo2, 76 | "Data", 77 | "Write message", 78 | "Close writer", 79 | "Quit", 80 | "Close", 81 | }) 82 | } 83 | 84 | func TestDialerSSLConfig(t *testing.T) { 85 | d := NewDialer(testHost, testSSLPort, "user", "pwd") 86 | d.LocalName = "test" 87 | d.TLSConfig = testConfig 88 | testSendMail(t, d, []string{ 89 | "Hello test", 90 | "Extension AUTH", 91 | "Auth", 92 | "Mail " + testFrom, 93 | "Rcpt " + testTo1, 94 | "Rcpt " + testTo2, 95 | "Data", 96 | "Write message", 97 | "Close writer", 98 | "Quit", 99 | "Close", 100 | }) 101 | } 102 | 103 | func TestDialerNoStartTLS(t *testing.T) { 104 | d := NewDialer(testHost, testPort, "user", "pwd") 105 | d.StartTLSPolicy = NoStartTLS 106 | testSendMail(t, d, []string{ 107 | "Extension AUTH", 108 | "Auth", 109 | "Mail " + testFrom, 110 | "Rcpt " + testTo1, 111 | "Rcpt " + testTo2, 112 | "Data", 113 | "Write message", 114 | "Close writer", 115 | "Quit", 116 | "Close", 117 | }) 118 | } 119 | 120 | func TestDialerOpportunisticStartTLS(t *testing.T) { 121 | d := NewDialer(testHost, testPort, "user", "pwd") 122 | d.StartTLSPolicy = OpportunisticStartTLS 123 | testSendMail(t, d, []string{ 124 | "Extension STARTTLS", 125 | "StartTLS", 126 | "Extension AUTH", 127 | "Auth", 128 | "Mail " + testFrom, 129 | "Rcpt " + testTo1, 130 | "Rcpt " + testTo2, 131 | "Data", 132 | "Write message", 133 | "Close writer", 134 | "Quit", 135 | "Close", 136 | }) 137 | 138 | if OpportunisticStartTLS != 0 { 139 | t.Errorf("OpportunisticStartTLS: expected 0, got %d", 140 | OpportunisticStartTLS) 141 | } 142 | } 143 | 144 | func TestDialerOpportunisticStartTLSUnsupported(t *testing.T) { 145 | d := NewDialer(testHost, testPort, "user", "pwd") 146 | d.StartTLSPolicy = OpportunisticStartTLS 147 | testSendMailStartTLSUnsupported(t, d, []string{ 148 | "Extension STARTTLS", 149 | "Extension AUTH", 150 | "Auth", 151 | "Mail " + testFrom, 152 | "Rcpt " + testTo1, 153 | "Rcpt " + testTo2, 154 | "Data", 155 | "Write message", 156 | "Close writer", 157 | "Quit", 158 | "Close", 159 | }) 160 | } 161 | 162 | func TestDialerMandatoryStartTLS(t *testing.T) { 163 | d := NewDialer(testHost, testPort, "user", "pwd") 164 | d.StartTLSPolicy = MandatoryStartTLS 165 | testSendMail(t, d, []string{ 166 | "Extension STARTTLS", 167 | "StartTLS", 168 | "Extension AUTH", 169 | "Auth", 170 | "Mail " + testFrom, 171 | "Rcpt " + testTo1, 172 | "Rcpt " + testTo2, 173 | "Data", 174 | "Write message", 175 | "Close writer", 176 | "Quit", 177 | "Close", 178 | }) 179 | } 180 | 181 | func TestDialerMandatoryStartTLSUnsupported(t *testing.T) { 182 | d := NewDialer(testHost, testPort, "user", "pwd") 183 | d.StartTLSPolicy = MandatoryStartTLS 184 | 185 | testClient := &mockClient{ 186 | t: t, 187 | addr: addr(d.Host, d.Port), 188 | config: d.TLSConfig, 189 | startTLS: false, 190 | timeout: true, 191 | } 192 | 193 | err := doTestSendMail(t, d, testClient, []string{ 194 | "Extension STARTTLS", 195 | }) 196 | 197 | if _, ok := err.(StartTLSUnsupportedError); !ok { 198 | t.Errorf("expected StartTLSUnsupportedError, but got: %s", 199 | reflect.TypeOf(err).Name()) 200 | } 201 | 202 | expected := "gomail: MandatoryStartTLS required, " + 203 | "but SMTP server does not support STARTTLS" 204 | if err.Error() != expected { 205 | t.Errorf("expected %s, but got: %s", expected, err) 206 | } 207 | } 208 | 209 | func TestDialerNoAuth(t *testing.T) { 210 | d := &Dialer{ 211 | Host: testHost, 212 | Port: testPort, 213 | } 214 | testSendMail(t, d, []string{ 215 | "Extension STARTTLS", 216 | "StartTLS", 217 | "Mail " + testFrom, 218 | "Rcpt " + testTo1, 219 | "Rcpt " + testTo2, 220 | "Data", 221 | "Write message", 222 | "Close writer", 223 | "Quit", 224 | "Close", 225 | }) 226 | } 227 | 228 | func TestDialerTimeout(t *testing.T) { 229 | d := &Dialer{ 230 | Host: testHost, 231 | Port: testPort, 232 | RetryFailure: true, 233 | } 234 | testSendMailTimeout(t, d, []string{ 235 | "Extension STARTTLS", 236 | "StartTLS", 237 | "Mail " + testFrom, 238 | "Extension STARTTLS", 239 | "StartTLS", 240 | "Mail " + testFrom, 241 | "Rcpt " + testTo1, 242 | "Rcpt " + testTo2, 243 | "Data", 244 | "Write message", 245 | "Close writer", 246 | "Quit", 247 | "Close", 248 | }) 249 | } 250 | 251 | func TestDialerTimeoutNoRetry(t *testing.T) { 252 | d := &Dialer{ 253 | Host: testHost, 254 | Port: testPort, 255 | RetryFailure: false, 256 | } 257 | testClient := &mockClient{ 258 | t: t, 259 | addr: addr(d.Host, d.Port), 260 | config: d.TLSConfig, 261 | startTLS: true, 262 | timeout: true, 263 | } 264 | 265 | err := doTestSendMail(t, d, testClient, []string{ 266 | "Extension STARTTLS", 267 | "StartTLS", 268 | "Mail " + testFrom, 269 | "Quit", 270 | }) 271 | require.EqualError(t, err, "gomail: could not send email 1: EOF") 272 | } 273 | 274 | type mockClient struct { 275 | t *testing.T 276 | i int 277 | want []string 278 | addr string 279 | config *tls.Config 280 | startTLS bool 281 | timeout bool 282 | } 283 | 284 | func (c *mockClient) Hello(localName string) error { 285 | c.do("Hello " + localName) 286 | return nil 287 | } 288 | 289 | func (c *mockClient) Extension(ext string) (bool, string) { 290 | c.do("Extension " + ext) 291 | ok := true 292 | if ext == "STARTTLS" { 293 | ok = c.startTLS 294 | } 295 | return ok, "" 296 | } 297 | 298 | func (c *mockClient) StartTLS(config *tls.Config) error { 299 | assertConfig(c.t, config, c.config) 300 | c.do("StartTLS") 301 | return nil 302 | } 303 | 304 | func (c *mockClient) Auth(a smtp.Auth) error { 305 | if !reflect.DeepEqual(a, testAuth) { 306 | c.t.Errorf("Invalid auth, got %#v, want %#v", a, testAuth) 307 | } 308 | c.do("Auth") 309 | return nil 310 | } 311 | 312 | func (c *mockClient) Mail(from string) error { 313 | c.do("Mail " + from) 314 | if c.timeout { 315 | c.timeout = false 316 | return io.EOF 317 | } 318 | return nil 319 | } 320 | 321 | func (c *mockClient) Rcpt(to string) error { 322 | c.do("Rcpt " + to) 323 | return nil 324 | } 325 | 326 | func (c *mockClient) Data() (io.WriteCloser, error) { 327 | c.do("Data") 328 | return &mockWriter{c: c, want: testMsg}, nil 329 | } 330 | 331 | func (c *mockClient) Quit() error { 332 | c.do("Quit") 333 | return nil 334 | } 335 | 336 | func (c *mockClient) Close() error { 337 | c.do("Close") 338 | return nil 339 | } 340 | 341 | func (c *mockClient) do(cmd string) { 342 | if c.i >= len(c.want) { 343 | c.t.Fatalf("Invalid command %q", cmd) 344 | } 345 | 346 | if cmd != c.want[c.i] { 347 | c.t.Fatalf("Invalid command, got %q, want %q", cmd, c.want[c.i]) 348 | } 349 | c.i++ 350 | } 351 | 352 | type mockWriter struct { 353 | want string 354 | c *mockClient 355 | buf bytes.Buffer 356 | } 357 | 358 | func (w *mockWriter) Write(p []byte) (int, error) { 359 | if w.buf.Len() == 0 { 360 | w.c.do("Write message") 361 | } 362 | w.buf.Write(p) 363 | return len(p), nil 364 | } 365 | 366 | func (w *mockWriter) Close() error { 367 | compareBodies(w.c.t, w.buf.String(), w.want) 368 | w.c.do("Close writer") 369 | return nil 370 | } 371 | 372 | func testSendMail(t *testing.T, d *Dialer, want []string) { 373 | testClient := &mockClient{ 374 | t: t, 375 | addr: addr(d.Host, d.Port), 376 | config: d.TLSConfig, 377 | startTLS: true, 378 | timeout: false, 379 | } 380 | 381 | require.NoError(t, doTestSendMail(t, d, testClient, want)) 382 | } 383 | 384 | func testSendMailStartTLSUnsupported(t *testing.T, d *Dialer, want []string) { 385 | testClient := &mockClient{ 386 | t: t, 387 | addr: addr(d.Host, d.Port), 388 | config: d.TLSConfig, 389 | startTLS: false, 390 | timeout: false, 391 | } 392 | 393 | require.NoError(t, doTestSendMail(t, d, testClient, want)) 394 | } 395 | 396 | func testSendMailTimeout(t *testing.T, d *Dialer, want []string) { 397 | testClient := &mockClient{ 398 | t: t, 399 | addr: addr(d.Host, d.Port), 400 | config: d.TLSConfig, 401 | startTLS: true, 402 | timeout: true, 403 | } 404 | 405 | require.NoError(t, doTestSendMail(t, d, testClient, want)) 406 | } 407 | 408 | func doTestSendMail(t *testing.T, d *Dialer, testClient *mockClient, want []string) error { 409 | testClient.want = want 410 | 411 | NetDialTimeout = func(network, address string, d time.Duration) (net.Conn, error) { 412 | require.Equal(t, "tcp", network) 413 | require.Equal(t, testClient.addr, address) 414 | return testConn, nil 415 | } 416 | 417 | tlsClient = func(conn net.Conn, config *tls.Config) *tls.Conn { 418 | require.Equal(t, testConn, conn) 419 | assertConfig(t, config, testClient.config) 420 | return testTLSConn 421 | } 422 | 423 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { 424 | if host != testHost { 425 | t.Errorf("Invalid host, got %q, want %q", host, testHost) 426 | } 427 | require.Equal(t, testHost, host) 428 | return testClient, nil 429 | } 430 | 431 | return d.DialAndSend(context.Background(), getTestMessage()) 432 | } 433 | 434 | func assertConfig(t *testing.T, got, want *tls.Config) { 435 | if want == nil { 436 | want = &tls.Config{ServerName: testHost} 437 | } 438 | if got.ServerName != want.ServerName { 439 | t.Errorf("Invalid field ServerName in config, got %q, want %q", got.ServerName, want.ServerName) 440 | } 441 | if got.InsecureSkipVerify != want.InsecureSkipVerify { 442 | t.Errorf("Invalid field InsecureSkipVerify in config, got %v, want %v", got.InsecureSkipVerify, want.InsecureSkipVerify) 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /writeto.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "encoding/base64" 5 | "github.com/pkg/errors" 6 | "io" 7 | "mime" 8 | "mime/multipart" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // WriteTo implements io.WriterTo. It dumps the whole message into w. 15 | func (m *Message) WriteTo(w io.Writer) (int64, error) { 16 | mw := &messageWriter{w: w} 17 | mw.writeMessage(m) 18 | return mw.n, mw.err 19 | } 20 | 21 | func (w *messageWriter) writeMessage(m *Message) { 22 | if _, ok := m.header["MIME-Version"]; !ok { 23 | w.writeString("MIME-Version: 1.0\r\n") 24 | } 25 | if _, ok := m.header["Date"]; !ok { 26 | w.writeHeader("Date", m.FormatDate(now())) 27 | } 28 | w.writeHeaders(m.header) 29 | 30 | if m.hasMixedPart() { 31 | w.openMultipart("mixed", m.boundary) 32 | } 33 | 34 | if m.hasRelatedPart() { 35 | w.openMultipart("related", m.boundary) 36 | } 37 | 38 | if m.hasAlternativePart() { 39 | w.openMultipart("alternative", m.boundary) 40 | } 41 | for _, part := range m.parts { 42 | w.writePart(part, m.charset) 43 | } 44 | if m.hasAlternativePart() { 45 | w.closeMultipart() 46 | } 47 | 48 | w.addFiles(m.embedded, false) 49 | if m.hasRelatedPart() { 50 | w.closeMultipart() 51 | } 52 | 53 | w.addFiles(m.attachments, true) 54 | if m.hasMixedPart() { 55 | w.closeMultipart() 56 | } 57 | } 58 | 59 | func (m *Message) hasMixedPart() bool { 60 | return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1 61 | } 62 | 63 | func (m *Message) hasRelatedPart() bool { 64 | return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1 65 | } 66 | 67 | func (m *Message) hasAlternativePart() bool { 68 | return len(m.parts) > 1 69 | } 70 | 71 | type messageWriter struct { 72 | w io.Writer 73 | n int64 74 | writers [3]*multipart.Writer 75 | partWriter io.Writer 76 | depth uint8 77 | err error 78 | } 79 | 80 | func (w *messageWriter) openMultipart(mimeType, boundary string) { 81 | mw := multipart.NewWriter(w) 82 | if boundary != "" { 83 | mw.SetBoundary(boundary) 84 | } 85 | contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary() 86 | w.writers[w.depth] = mw 87 | 88 | if w.depth == 0 { 89 | w.writeHeader("Content-Type", contentType) 90 | w.writeString("\r\n") 91 | } else { 92 | w.createPart(map[string][]string{ 93 | "Content-Type": {contentType}, 94 | }) 95 | } 96 | w.depth++ 97 | } 98 | 99 | func (w *messageWriter) createPart(h map[string][]string) { 100 | w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h) 101 | } 102 | 103 | func (w *messageWriter) closeMultipart() { 104 | if w.depth > 0 { 105 | w.writers[w.depth-1].Close() 106 | w.depth-- 107 | } 108 | } 109 | 110 | func (w *messageWriter) writePart(p *part, charset string) { 111 | w.writeHeaders(map[string][]string{ 112 | "Content-Type": {p.contentType + "; charset=" + charset}, 113 | "Content-Transfer-Encoding": {string(p.encoding)}, 114 | }) 115 | w.writeBody(p.copier, p.encoding) 116 | } 117 | 118 | func (w *messageWriter) addFiles(files []*file, isAttachment bool) { 119 | for _, f := range files { 120 | if _, ok := f.Header["Content-Type"]; !ok { 121 | mediaType := mime.TypeByExtension(filepath.Ext(f.Name)) 122 | if mediaType == "" { 123 | mediaType = "application/octet-stream" 124 | } 125 | f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`) 126 | } 127 | 128 | if _, ok := f.Header["Content-Transfer-Encoding"]; !ok { 129 | f.setHeader("Content-Transfer-Encoding", string(Base64)) 130 | } 131 | 132 | if _, ok := f.Header["Content-Disposition"]; !ok { 133 | var disp string 134 | if isAttachment { 135 | disp = "attachment" 136 | } else { 137 | disp = "inline" 138 | } 139 | f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`) 140 | } 141 | 142 | if !isAttachment { 143 | if _, ok := f.Header["Content-ID"]; !ok { 144 | f.setHeader("Content-ID", "<"+f.Name+">") 145 | } 146 | } 147 | w.writeHeaders(f.Header) 148 | w.writeBody(f.CopyFunc, Base64) 149 | } 150 | } 151 | 152 | func (w *messageWriter) Write(p []byte) (int, error) { 153 | if w.err != nil { 154 | return 0, errors.New("gomail: cannot write as writer is in error") 155 | } 156 | 157 | var n int 158 | n, w.err = w.w.Write(p) 159 | w.n += int64(n) 160 | return n, w.err 161 | } 162 | 163 | func (w *messageWriter) writeString(s string) { 164 | if w.err != nil { // do nothing when in error 165 | return 166 | } 167 | var n int 168 | n, w.err = io.WriteString(w.w, s) 169 | w.n += int64(n) 170 | } 171 | 172 | func (w *messageWriter) writeHeader(k string, v ...string) { 173 | w.writeString(k) 174 | if len(v) == 0 { 175 | w.writeString(":\r\n") 176 | return 177 | } 178 | w.writeString(": ") 179 | 180 | // Max header line length is 78 characters in RFC 5322 and 76 characters 181 | // in RFC 2047. So for the sake of simplicity we use the 76 characters 182 | // limit. 183 | charsLeft := 76 - len(k) - len(": ") 184 | 185 | for i, s := range v { 186 | // If the line is already too long, insert a newline right away. 187 | if charsLeft < 1 { 188 | if i == 0 { 189 | w.writeString("\r\n ") 190 | } else { 191 | w.writeString(",\r\n ") 192 | } 193 | charsLeft = 75 194 | } else if i != 0 { 195 | w.writeString(", ") 196 | charsLeft -= 2 197 | } 198 | 199 | // While the header content is too long, fold it by inserting a newline. 200 | for len(s) > charsLeft { 201 | s = w.writeLine(s, charsLeft) 202 | charsLeft = 75 203 | } 204 | w.writeString(s) 205 | if i := lastIndexByte(s, '\n'); i != -1 { 206 | charsLeft = 75 - (len(s) - i - 1) 207 | } else { 208 | charsLeft -= len(s) 209 | } 210 | } 211 | w.writeString("\r\n") 212 | } 213 | 214 | func (w *messageWriter) writeLine(s string, charsLeft int) string { 215 | // If there is already a newline before the limit. Write the line. 216 | if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft { 217 | w.writeString(s[:i+1]) 218 | return s[i+1:] 219 | } 220 | 221 | for i := charsLeft - 1; i >= 0; i-- { 222 | if s[i] == ' ' { 223 | w.writeString(s[:i]) 224 | w.writeString("\r\n ") 225 | return s[i+1:] 226 | } 227 | } 228 | 229 | // We could not insert a newline cleanly so look for a space or a newline 230 | // even if it is after the limit. 231 | for i := 75; i < len(s); i++ { 232 | if s[i] == ' ' { 233 | w.writeString(s[:i]) 234 | w.writeString("\r\n ") 235 | return s[i+1:] 236 | } 237 | if s[i] == '\n' { 238 | w.writeString(s[:i+1]) 239 | return s[i+1:] 240 | } 241 | } 242 | 243 | // Too bad, no space or newline in the whole string. Just write everything. 244 | w.writeString(s) 245 | return "" 246 | } 247 | 248 | func (w *messageWriter) writeHeaders(h map[string][]string) { 249 | if w.depth == 0 { 250 | for k, v := range h { 251 | if k != "Bcc" { 252 | w.writeHeader(k, v...) 253 | } 254 | } 255 | } else { 256 | w.createPart(h) 257 | } 258 | } 259 | 260 | func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) { 261 | if f == nil { 262 | w.err = errors.New("writeBody: expected writer to be defined but got nil") 263 | return 264 | } 265 | 266 | var subWriter io.Writer 267 | if w.depth == 0 { 268 | w.writeString("\r\n") 269 | subWriter = w.w 270 | } else { 271 | subWriter = w.partWriter 272 | } 273 | 274 | if enc == Base64 { 275 | wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) 276 | w.err = f(wc) 277 | wc.Close() 278 | } else if enc == Unencoded { 279 | w.err = f(subWriter) 280 | } else { 281 | wc := newQPWriter(subWriter) 282 | w.err = f(wc) 283 | wc.Close() 284 | } 285 | } 286 | 287 | // As required by RFC 2045, 6.7. (page 21) for quoted-printable, and 288 | // RFC 2045, 6.8. (page 25) for base64. 289 | const maxLineLen = 76 290 | 291 | // base64LineWriter limits text encoded in base64 to 76 characters per line 292 | type base64LineWriter struct { 293 | w io.Writer 294 | lineLen int 295 | } 296 | 297 | func newBase64LineWriter(w io.Writer) *base64LineWriter { 298 | return &base64LineWriter{w: w} 299 | } 300 | 301 | func (w *base64LineWriter) Write(p []byte) (int, error) { 302 | n := 0 303 | for len(p)+w.lineLen > maxLineLen { 304 | w.w.Write(p[:maxLineLen-w.lineLen]) 305 | w.w.Write([]byte("\r\n")) 306 | p = p[maxLineLen-w.lineLen:] 307 | n += maxLineLen - w.lineLen 308 | w.lineLen = 0 309 | } 310 | 311 | w.w.Write(p) 312 | w.lineLen += len(p) 313 | 314 | return n + len(p), nil 315 | } 316 | 317 | // Stubbed out for testing. 318 | var now = time.Now 319 | --------------------------------------------------------------------------------