├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── auth.go ├── auth_test.go ├── doc.go ├── errors.go ├── example_test.go ├── message.go ├── message_test.go ├── mime.go ├── mime_go14.go ├── send.go ├── send_test.go ├── smtp.go ├── smtp_test.go └── writeto.go /.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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.2 5 | - 1.3 6 | - 1.4 7 | - 1.5 8 | - 1.6 9 | - 1.7 10 | - 1.8 11 | - 1.9 12 | - master 13 | 14 | # safelist 15 | branches: 16 | only: 17 | - master 18 | - v2 19 | 20 | notifications: 21 | email: false 22 | 23 | before_install: 24 | - mkdir -p $GOPATH/src/gopkg.in && 25 | ln -s ../github.com/go-mail/mail $GOPATH/src/gopkg.in/mail.v2 26 | -------------------------------------------------------------------------------- /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 | # Gomail 2 | [![Build Status](https://travis-ci.org/go-mail/mail.svg?branch=master)](https://travis-ci.org/go-mail/mail) [![Code Coverage](http://gocover.io/_badge/github.com/go-mail/mail)](http://gocover.io/github.com/go-mail/mail) [![Documentation](https://godoc.org/github.com/go-mail/mail?status.svg)](https://godoc.org/github.com/go-mail/mail) 3 | 4 | This is an actively maintained fork of [Gomail][1] and includes fixes and 5 | improvements for a number of outstanding issues. The current progress is 6 | as follows: 7 | 8 | - [x] Timeouts and retries can be specified outside of the 10 second default. 9 | - [x] Proxying is supported through specifying a custom [NetDialTimeout][2]. 10 | - [ ] Filenames are properly encoded for non-ASCII characters. 11 | - [ ] Email addresses are properly encoded for non-ASCII characters. 12 | - [ ] Embedded files and attachments are tested for their existence. 13 | - [ ] An `io.Reader` can be supplied when embedding and attaching files. 14 | 15 | See [Transitioning Existing Codebases][3] for more information on switching. 16 | 17 | [1]: https://github.com/go-gomail/gomail 18 | [2]: https://godoc.org/gopkg.in/mail.v2#NetDialTimeout 19 | [3]: #transitioning-existing-codebases 20 | 21 | ## Introduction 22 | 23 | Gomail is a simple and efficient package to send emails. It is well tested and 24 | documented. 25 | 26 | Gomail can only send emails using an SMTP server. But the API is flexible and it 27 | is easy to implement other methods for sending emails using a local Postfix, an 28 | API, etc. 29 | 30 | It requires Go 1.2 or newer. With Go 1.5, no external dependencies are used. 31 | 32 | 33 | ## Features 34 | 35 | Gomail supports: 36 | - Attachments 37 | - Embedded images 38 | - HTML and text templates 39 | - Automatic encoding of special characters 40 | - SSL and TLS 41 | - Sending multiple emails with the same SMTP connection 42 | 43 | 44 | ## Documentation 45 | 46 | https://godoc.org/github.com/go-mail/mail 47 | 48 | 49 | ## Download 50 | 51 | If you're already using a dependency manager, like [dep][dep], use the following 52 | import path: 53 | 54 | ``` 55 | github.com/go-mail/mail 56 | ``` 57 | 58 | If you *aren't* using vendoring, `go get` the [Gopkg.in](http://gopkg.in) 59 | import path: 60 | 61 | ``` 62 | gopkg.in/mail.v2 63 | ``` 64 | 65 | [dep]: https://github.com/golang/dep#readme 66 | 67 | ## Examples 68 | 69 | See the [examples in the documentation](https://godoc.org/github.com/go-mail/mail#example-package). 70 | 71 | 72 | ## FAQ 73 | 74 | ### x509: certificate signed by unknown authority 75 | 76 | If you get this error it means the certificate used by the SMTP server is not 77 | considered valid by the client running Gomail. As a quick workaround you can 78 | bypass the verification of the server's certificate chain and host name by using 79 | `SetTLSConfig`: 80 | 81 | ```go 82 | package main 83 | 84 | import ( 85 | "crypto/tls" 86 | 87 | "gopkg.in/mail.v2" 88 | ) 89 | 90 | func main() { 91 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 92 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true} 93 | 94 | // Send emails using d. 95 | } 96 | ``` 97 | 98 | Note, however, that this is insecure and should not be used in production. 99 | 100 | ### Transitioning Existing Codebases 101 | 102 | If you're already using the original Gomail, switching is as easy as updating 103 | the import line to: 104 | 105 | ``` 106 | import gomail "gopkg.in/mail.v2" 107 | ``` 108 | 109 | ## Contribute 110 | 111 | Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for 112 | more info. 113 | 114 | 115 | ## Change log 116 | 117 | See [CHANGELOG.md](CHANGELOG.md). 118 | 119 | 120 | ## License 121 | 122 | [MIT](LICENSE) 123 | 124 | 125 | ## Support & Contact 126 | 127 | You can ask questions on the [Gomail 128 | thread](https://groups.google.com/d/topic/golang-nuts/jMxZHzvvEVg/discussion) 129 | in the Go mailing-list. 130 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 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 | "fmt" 5 | "html/template" 6 | "io" 7 | "log" 8 | "time" 9 | 10 | "gopkg.in/mail.v2" 11 | ) 12 | 13 | func Example() { 14 | m := mail.NewMessage() 15 | m.SetHeader("From", "alex@example.com") 16 | m.SetHeader("To", "bob@example.com", "cora@example.com") 17 | m.SetAddressHeader("Cc", "dan@example.com", "Dan") 18 | m.SetHeader("Subject", "Hello!") 19 | m.SetBody("text/html", "Hello Bob and Cora!") 20 | m.Attach("/home/Alex/lolcat.jpg") 21 | 22 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 23 | d.StartTLSPolicy = mail.MandatoryStartTLS 24 | 25 | // Send the email to Bob, Cora and Dan. 26 | if err := d.DialAndSend(m); err != nil { 27 | panic(err) 28 | } 29 | } 30 | 31 | // A daemon that listens to a channel and sends all incoming messages. 32 | func Example_daemon() { 33 | ch := make(chan *mail.Message) 34 | 35 | go func() { 36 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 37 | d.StartTLSPolicy = mail.MandatoryStartTLS 38 | 39 | var s mail.SendCloser 40 | var err error 41 | open := false 42 | for { 43 | select { 44 | case m, ok := <-ch: 45 | if !ok { 46 | return 47 | } 48 | if !open { 49 | if s, err = d.Dial(); err != nil { 50 | panic(err) 51 | } 52 | open = true 53 | } 54 | if err := mail.Send(s, m); err != nil { 55 | log.Print(err) 56 | } 57 | // Close the connection to the SMTP server if no email was sent in 58 | // the last 30 seconds. 59 | case <-time.After(30 * time.Second): 60 | if open { 61 | if err := s.Close(); err != nil { 62 | panic(err) 63 | } 64 | open = false 65 | } 66 | } 67 | } 68 | }() 69 | 70 | // Use the channel in your program to send emails. 71 | 72 | // Close the channel to stop the mail daemon. 73 | close(ch) 74 | } 75 | 76 | // Efficiently send a customized newsletter to a list of recipients. 77 | func Example_newsletter() { 78 | // The list of recipients. 79 | var list []struct { 80 | Name string 81 | Address string 82 | } 83 | 84 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456") 85 | d.StartTLSPolicy = mail.MandatoryStartTLS 86 | s, err := d.Dial() 87 | if err != nil { 88 | panic(err) 89 | } 90 | 91 | m := mail.NewMessage() 92 | for _, r := range list { 93 | m.SetHeader("From", "no-reply@example.com") 94 | m.SetAddressHeader("To", r.Address, r.Name) 95 | m.SetHeader("Subject", "Newsletter #1") 96 | m.SetBody("text/html", fmt.Sprintf("Hello %s!", r.Name)) 97 | 98 | if err := mail.Send(s, m); err != nil { 99 | log.Printf("Could not send email to %q: %v", r.Address, err) 100 | } 101 | m.Reset() 102 | } 103 | } 104 | 105 | // Send an email using a local SMTP server. 106 | func Example_noAuth() { 107 | m := mail.NewMessage() 108 | m.SetHeader("From", "from@example.com") 109 | m.SetHeader("To", "to@example.com") 110 | m.SetHeader("Subject", "Hello!") 111 | m.SetBody("text/plain", "Hello!") 112 | 113 | d := mail.Dialer{Host: "localhost", Port: 587} 114 | if err := d.DialAndSend(m); err != nil { 115 | panic(err) 116 | } 117 | } 118 | 119 | // Send an email using an API or postfix. 120 | func Example_noSMTP() { 121 | m := mail.NewMessage() 122 | m.SetHeader("From", "from@example.com") 123 | m.SetHeader("To", "to@example.com") 124 | m.SetHeader("Subject", "Hello!") 125 | m.SetBody("text/plain", "Hello!") 126 | 127 | s := mail.SendFunc(func(from string, to []string, msg io.WriterTo) error { 128 | // Implements you email-sending function, for example by calling 129 | // an API, or running postfix, etc. 130 | fmt.Println("From:", from) 131 | fmt.Println("To:", to) 132 | return nil 133 | }) 134 | 135 | if err := mail.Send(s, m); err != nil { 136 | panic(err) 137 | } 138 | // Output: 139 | // From: from@example.com 140 | // To: [to@example.com] 141 | } 142 | 143 | var m *mail.Message 144 | 145 | func ExampleSetCopyFunc() { 146 | m.Attach("foo.txt", mail.SetCopyFunc(func(w io.Writer) error { 147 | _, err := w.Write([]byte("Content of foo.txt")) 148 | return err 149 | })) 150 | } 151 | 152 | func ExampleSetHeader() { 153 | h := map[string][]string{"Content-ID": {""}} 154 | m.Attach("foo.jpg", mail.SetHeader(h)) 155 | } 156 | 157 | func ExampleRename() { 158 | m.Attach("/tmp/0000146.jpg", mail.Rename("picture.jpg")) 159 | } 160 | 161 | func ExampleMessage_AddAlternative() { 162 | m.SetBody("text/plain", "Hello!") 163 | m.AddAlternative("text/html", "

Hello!

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

¡Hola, señor!

") 818 | m.Attach(mockCopyFile("benchmark.txt")) 819 | m.Embed(mockCopyFile("benchmark.jpg")) 820 | 821 | if err := Send(discardFunc, m); err != nil { 822 | panic(err) 823 | } 824 | m.Reset() 825 | } 826 | } 827 | -------------------------------------------------------------------------------- /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 | "errors" 5 | "fmt" 6 | "io" 7 | stdmail "net/mail" 8 | ) 9 | 10 | // Sender is the interface that wraps the Send method. 11 | // 12 | // Send sends an email to the given addresses. 13 | type Sender interface { 14 | Send(from string, to []string, msg io.WriterTo) error 15 | } 16 | 17 | // SendCloser is the interface that groups the Send and Close methods. 18 | type SendCloser interface { 19 | Sender 20 | Close() error 21 | } 22 | 23 | // A SendFunc is a function that sends emails to the given addresses. 24 | // 25 | // The SendFunc type is an adapter to allow the use of ordinary functions as 26 | // email senders. If f is a function with the appropriate signature, SendFunc(f) 27 | // is a Sender object that calls f. 28 | type SendFunc func(from string, to []string, msg io.WriterTo) error 29 | 30 | // Send calls f(from, to, msg). 31 | func (f SendFunc) Send(from string, to []string, msg io.WriterTo) error { 32 | return f(from, to, msg) 33 | } 34 | 35 | // Send sends emails using the given Sender. 36 | func Send(s Sender, msg ...*Message) error { 37 | for i, m := range msg { 38 | if err := send(s, m); err != nil { 39 | return &SendError{Cause: err, Index: uint(i)} 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | 46 | func send(s Sender, m *Message) error { 47 | from, err := m.getFrom() 48 | if err != nil { 49 | return err 50 | } 51 | 52 | to, err := m.getRecipients() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if err := s.Send(from, to, m); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (m *Message) getFrom() (string, error) { 65 | from := m.header["Sender"] 66 | if len(from) == 0 { 67 | from = m.header["From"] 68 | if len(from) == 0 { 69 | return "", errors.New(`gomail: invalid message, "From" field is absent`) 70 | } 71 | } 72 | 73 | return parseAddress(from[0]) 74 | } 75 | 76 | func (m *Message) getRecipients() ([]string, error) { 77 | n := 0 78 | for _, field := range []string{"To", "Cc", "Bcc"} { 79 | if addresses, ok := m.header[field]; ok { 80 | n += len(addresses) 81 | } 82 | } 83 | list := make([]string, 0, n) 84 | 85 | for _, field := range []string{"To", "Cc", "Bcc"} { 86 | if addresses, ok := m.header[field]; ok { 87 | for _, a := range addresses { 88 | addr, err := parseAddress(a) 89 | if err != nil { 90 | return nil, err 91 | } 92 | list = addAddress(list, addr) 93 | } 94 | } 95 | } 96 | 97 | return list, nil 98 | } 99 | 100 | func addAddress(list []string, addr string) []string { 101 | for _, a := range list { 102 | if addr == a { 103 | return list 104 | } 105 | } 106 | 107 | return append(list, addr) 108 | } 109 | 110 | func parseAddress(field string) (string, error) { 111 | addr, err := stdmail.ParseAddress(field) 112 | if err != nil { 113 | return "", fmt.Errorf("gomail: invalid address %q: %v", field, err) 114 | } 115 | return addr.Address, nil 116 | } 117 | -------------------------------------------------------------------------------- /send_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | const ( 12 | testTo1 = "to1@example.com" 13 | testTo2 = "to2@example.com" 14 | testFrom = "from@example.com" 15 | testBody = "Test message" 16 | testMsg = "To: " + testTo1 + ", " + testTo2 + "\r\n" + 17 | "From: " + testFrom + "\r\n" + 18 | "MIME-Version: 1.0\r\n" + 19 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 20 | "Content-Type: text/plain; charset=UTF-8\r\n" + 21 | "Content-Transfer-Encoding: quoted-printable\r\n" + 22 | "\r\n" + 23 | testBody 24 | ) 25 | 26 | type mockSender SendFunc 27 | 28 | func (s mockSender) Send(from string, to []string, msg io.WriterTo) error { 29 | return s(from, to, msg) 30 | } 31 | 32 | type mockSendCloser struct { 33 | mockSender 34 | close func() error 35 | } 36 | 37 | func (s *mockSendCloser) Close() error { 38 | return s.close() 39 | } 40 | 41 | func TestSend(t *testing.T) { 42 | s := &mockSendCloser{ 43 | mockSender: stubSend(t, testFrom, []string{testTo1, testTo2}, testMsg), 44 | close: func() error { 45 | t.Error("Close() should not be called in Send()") 46 | return nil 47 | }, 48 | } 49 | if err := Send(s, getTestMessage()); err != nil { 50 | t.Errorf("Send(): %v", err) 51 | } 52 | } 53 | 54 | func TestSendError(t *testing.T) { 55 | s := &mockSendCloser{ 56 | mockSender: func(_ string, _ []string, _ io.WriterTo) error { 57 | return errors.New("kaboom") 58 | }, 59 | } 60 | wantErr := "gomail: could not send email 1: kaboom" 61 | if err := Send(s, getTestMessage()); err == nil || err.Error() != wantErr { 62 | t.Errorf("expected Send() error, got %q, want %q", err, wantErr) 63 | } 64 | } 65 | 66 | func getTestMessage() *Message { 67 | m := NewMessage() 68 | m.SetHeader("From", testFrom) 69 | m.SetHeader("To", testTo1, testTo2) 70 | m.SetBody("text/plain", testBody) 71 | 72 | return m 73 | } 74 | 75 | func stubSend(t *testing.T, wantFrom string, wantTo []string, wantBody string) mockSender { 76 | return func(from string, to []string, msg io.WriterTo) error { 77 | if from != wantFrom { 78 | t.Errorf("invalid from, got %q, want %q", from, wantFrom) 79 | } 80 | if !reflect.DeepEqual(to, wantTo) { 81 | t.Errorf("invalid to, got %v, want %v", to, wantTo) 82 | } 83 | 84 | buf := new(bytes.Buffer) 85 | _, err := msg.WriteTo(buf) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | compareBodies(t, buf.String(), wantBody) 90 | 91 | return nil 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /smtp.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/smtp" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // A Dialer is a dialer to an SMTP server. 14 | type Dialer struct { 15 | // Host represents the host of the SMTP server. 16 | Host string 17 | // Port represents the port of the SMTP server. 18 | Port int 19 | // Username is the username to use to authenticate to the SMTP server. 20 | Username string 21 | // Password is the password to use to authenticate to the SMTP server. 22 | Password string 23 | // Auth represents the authentication mechanism used to authenticate to the 24 | // SMTP server. 25 | Auth smtp.Auth 26 | // SSL defines whether an SSL connection is used. It should be false in 27 | // most cases since the authentication mechanism should use the STARTTLS 28 | // extension instead. 29 | SSL bool 30 | // TLSConfig represents the TLS configuration used for the TLS (when the 31 | // STARTTLS extension is used) or SSL connection. 32 | TLSConfig *tls.Config 33 | // StartTLSPolicy represents the TLS security level required to 34 | // communicate with the SMTP server. 35 | // 36 | // This defaults to OpportunisticStartTLS for backwards compatibility, 37 | // but we recommend MandatoryStartTLS for all modern SMTP servers. 38 | // 39 | // This option has no effect if SSL is set to true. 40 | StartTLSPolicy StartTLSPolicy 41 | // LocalName is the hostname sent to the SMTP server with the HELO command. 42 | // By default, "localhost" is sent. 43 | LocalName string 44 | // Timeout to use for read/write operations. Defaults to 10 seconds, can 45 | // be set to 0 to disable timeouts. 46 | Timeout time.Duration 47 | // Whether we should retry mailing if the connection returned an error, 48 | // defaults to true. 49 | RetryFailure bool 50 | } 51 | 52 | // NewDialer returns a new SMTP Dialer. The given parameters are used to connect 53 | // to the SMTP server. 54 | func NewDialer(host string, port int, username, password string) *Dialer { 55 | return &Dialer{ 56 | Host: host, 57 | Port: port, 58 | Username: username, 59 | Password: password, 60 | SSL: port == 465, 61 | Timeout: 10 * time.Second, 62 | RetryFailure: true, 63 | } 64 | } 65 | 66 | // NewPlainDialer returns a new SMTP Dialer. The given parameters are used to 67 | // connect to the SMTP server. 68 | // 69 | // Deprecated: Use NewDialer instead. 70 | func NewPlainDialer(host string, port int, username, password string) *Dialer { 71 | return NewDialer(host, port, username, password) 72 | } 73 | 74 | // NetDialTimeout specifies the DialTimeout function to establish a connection 75 | // to the SMTP server. This can be used to override dialing in the case that a 76 | // proxy or other special behavior is needed. 77 | var NetDialTimeout = net.DialTimeout 78 | 79 | // Dial dials and authenticates to an SMTP server. The returned SendCloser 80 | // should be closed when done using it. 81 | func (d *Dialer) Dial() (SendCloser, error) { 82 | conn, err := NetDialTimeout("tcp", addr(d.Host, d.Port), d.Timeout) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if d.SSL { 88 | conn = tlsClient(conn, d.tlsConfig()) 89 | } 90 | 91 | c, err := smtpNewClient(conn, d.Host) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | if d.Timeout > 0 { 97 | conn.SetDeadline(time.Now().Add(d.Timeout)) 98 | } 99 | 100 | if d.LocalName != "" { 101 | if err := c.Hello(d.LocalName); err != nil { 102 | return nil, err 103 | } 104 | } 105 | 106 | if !d.SSL && d.StartTLSPolicy != NoStartTLS { 107 | ok, _ := c.Extension("STARTTLS") 108 | if !ok && d.StartTLSPolicy == MandatoryStartTLS { 109 | err := StartTLSUnsupportedError{ 110 | Policy: d.StartTLSPolicy} 111 | return nil, err 112 | } 113 | 114 | if ok { 115 | if err := c.StartTLS(d.tlsConfig()); err != nil { 116 | c.Close() 117 | return nil, err 118 | } 119 | } 120 | } 121 | 122 | if d.Auth == nil && d.Username != "" { 123 | if ok, auths := c.Extension("AUTH"); ok { 124 | if strings.Contains(auths, "CRAM-MD5") { 125 | d.Auth = smtp.CRAMMD5Auth(d.Username, d.Password) 126 | } else if strings.Contains(auths, "LOGIN") && 127 | !strings.Contains(auths, "PLAIN") { 128 | d.Auth = &loginAuth{ 129 | username: d.Username, 130 | password: d.Password, 131 | host: d.Host, 132 | } 133 | } else { 134 | d.Auth = smtp.PlainAuth("", d.Username, d.Password, d.Host) 135 | } 136 | } 137 | } 138 | 139 | if d.Auth != nil { 140 | if err = c.Auth(d.Auth); err != nil { 141 | c.Close() 142 | return nil, err 143 | } 144 | } 145 | 146 | return &smtpSender{c, conn, d}, nil 147 | } 148 | 149 | func (d *Dialer) tlsConfig() *tls.Config { 150 | if d.TLSConfig == nil { 151 | return &tls.Config{ServerName: d.Host} 152 | } 153 | return d.TLSConfig 154 | } 155 | 156 | // StartTLSPolicy constants are valid values for Dialer.StartTLSPolicy. 157 | type StartTLSPolicy int 158 | 159 | const ( 160 | // OpportunisticStartTLS means that SMTP transactions are encrypted if 161 | // STARTTLS is supported by the SMTP server. Otherwise, messages are 162 | // sent in the clear. This is the default setting. 163 | OpportunisticStartTLS StartTLSPolicy = iota 164 | // MandatoryStartTLS means that SMTP transactions must be encrypted. 165 | // SMTP transactions are aborted unless STARTTLS is supported by the 166 | // SMTP server. 167 | MandatoryStartTLS 168 | // NoStartTLS means encryption is disabled and messages are sent in the 169 | // clear. 170 | NoStartTLS = -1 171 | ) 172 | 173 | func (policy *StartTLSPolicy) String() string { 174 | switch *policy { 175 | case OpportunisticStartTLS: 176 | return "OpportunisticStartTLS" 177 | case MandatoryStartTLS: 178 | return "MandatoryStartTLS" 179 | case NoStartTLS: 180 | return "NoStartTLS" 181 | default: 182 | return fmt.Sprintf("StartTLSPolicy:%v", *policy) 183 | } 184 | } 185 | 186 | // StartTLSUnsupportedError is returned by Dial when connecting to an SMTP 187 | // server that does not support STARTTLS. 188 | type StartTLSUnsupportedError struct { 189 | Policy StartTLSPolicy 190 | } 191 | 192 | func (e StartTLSUnsupportedError) Error() string { 193 | return "gomail: " + e.Policy.String() + " required, but " + 194 | "SMTP server does not support STARTTLS" 195 | } 196 | 197 | func addr(host string, port int) string { 198 | return fmt.Sprintf("%s:%d", host, port) 199 | } 200 | 201 | // DialAndSend opens a connection to the SMTP server, sends the given emails and 202 | // closes the connection. 203 | func (d *Dialer) DialAndSend(m ...*Message) error { 204 | s, err := d.Dial() 205 | if err != nil { 206 | return err 207 | } 208 | defer s.Close() 209 | 210 | return Send(s, m...) 211 | } 212 | 213 | type smtpSender struct { 214 | smtpClient 215 | conn net.Conn 216 | d *Dialer 217 | } 218 | 219 | func (c *smtpSender) retryError(err error) bool { 220 | if !c.d.RetryFailure { 221 | return false 222 | } 223 | 224 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 225 | return true 226 | } 227 | 228 | return err == io.EOF 229 | } 230 | 231 | func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error { 232 | if c.d.Timeout > 0 { 233 | c.conn.SetDeadline(time.Now().Add(c.d.Timeout)) 234 | } 235 | 236 | if err := c.Mail(from); err != nil { 237 | if c.retryError(err) { 238 | // This is probably due to a timeout, so reconnect and try again. 239 | sc, derr := c.d.Dial() 240 | if derr == nil { 241 | if s, ok := sc.(*smtpSender); ok { 242 | *c = *s 243 | return c.Send(from, to, msg) 244 | } 245 | } 246 | } 247 | 248 | return err 249 | } 250 | 251 | for _, addr := range to { 252 | if err := c.Rcpt(addr); err != nil { 253 | return err 254 | } 255 | } 256 | 257 | w, err := c.Data() 258 | if err != nil { 259 | return err 260 | } 261 | 262 | if _, err = msg.WriteTo(w); err != nil { 263 | w.Close() 264 | return err 265 | } 266 | 267 | return w.Close() 268 | } 269 | 270 | func (c *smtpSender) Close() error { 271 | return c.Quit() 272 | } 273 | 274 | // Stubbed out for tests. 275 | var ( 276 | tlsClient = tls.Client 277 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { 278 | return smtp.NewClient(conn, host) 279 | } 280 | ) 281 | 282 | type smtpClient interface { 283 | Hello(string) error 284 | Extension(string) (bool, string) 285 | StartTLS(*tls.Config) error 286 | Auth(smtp.Auth) error 287 | Mail(string) error 288 | Rcpt(string) error 289 | Data() (io.WriteCloser, error) 290 | Quit() error 291 | Close() error 292 | } 293 | -------------------------------------------------------------------------------- /smtp_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "io" 7 | "net" 8 | "net/smtp" 9 | "reflect" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | const ( 15 | testPort = 587 16 | testSSLPort = 465 17 | ) 18 | 19 | var ( 20 | testConn = &net.TCPConn{} 21 | testTLSConn = tls.Client(testConn, &tls.Config{InsecureSkipVerify: true}) 22 | testConfig = &tls.Config{InsecureSkipVerify: true} 23 | testAuth = smtp.PlainAuth("", testUser, testPwd, testHost) 24 | ) 25 | 26 | func TestDialer(t *testing.T) { 27 | d := NewDialer(testHost, testPort, "user", "pwd") 28 | testSendMail(t, d, []string{ 29 | "Extension STARTTLS", 30 | "StartTLS", 31 | "Extension AUTH", 32 | "Auth", 33 | "Mail " + testFrom, 34 | "Rcpt " + testTo1, 35 | "Rcpt " + testTo2, 36 | "Data", 37 | "Write message", 38 | "Close writer", 39 | "Quit", 40 | "Close", 41 | }) 42 | } 43 | 44 | func TestDialerSSL(t *testing.T) { 45 | d := NewDialer(testHost, testSSLPort, "user", "pwd") 46 | testSendMail(t, d, []string{ 47 | "Extension AUTH", 48 | "Auth", 49 | "Mail " + testFrom, 50 | "Rcpt " + testTo1, 51 | "Rcpt " + testTo2, 52 | "Data", 53 | "Write message", 54 | "Close writer", 55 | "Quit", 56 | "Close", 57 | }) 58 | } 59 | 60 | func TestDialerConfig(t *testing.T) { 61 | d := NewDialer(testHost, testPort, "user", "pwd") 62 | d.LocalName = "test" 63 | d.TLSConfig = testConfig 64 | testSendMail(t, d, []string{ 65 | "Hello test", 66 | "Extension STARTTLS", 67 | "StartTLS", 68 | "Extension AUTH", 69 | "Auth", 70 | "Mail " + testFrom, 71 | "Rcpt " + testTo1, 72 | "Rcpt " + testTo2, 73 | "Data", 74 | "Write message", 75 | "Close writer", 76 | "Quit", 77 | "Close", 78 | }) 79 | } 80 | 81 | func TestDialerSSLConfig(t *testing.T) { 82 | d := NewDialer(testHost, testSSLPort, "user", "pwd") 83 | d.LocalName = "test" 84 | d.TLSConfig = testConfig 85 | testSendMail(t, d, []string{ 86 | "Hello test", 87 | "Extension AUTH", 88 | "Auth", 89 | "Mail " + testFrom, 90 | "Rcpt " + testTo1, 91 | "Rcpt " + testTo2, 92 | "Data", 93 | "Write message", 94 | "Close writer", 95 | "Quit", 96 | "Close", 97 | }) 98 | } 99 | 100 | func TestDialerNoStartTLS(t *testing.T) { 101 | d := NewDialer(testHost, testPort, "user", "pwd") 102 | d.StartTLSPolicy = NoStartTLS 103 | testSendMail(t, d, []string{ 104 | "Extension AUTH", 105 | "Auth", 106 | "Mail " + testFrom, 107 | "Rcpt " + testTo1, 108 | "Rcpt " + testTo2, 109 | "Data", 110 | "Write message", 111 | "Close writer", 112 | "Quit", 113 | "Close", 114 | }) 115 | } 116 | 117 | func TestDialerOpportunisticStartTLS(t *testing.T) { 118 | d := NewDialer(testHost, testPort, "user", "pwd") 119 | d.StartTLSPolicy = OpportunisticStartTLS 120 | testSendMail(t, d, []string{ 121 | "Extension STARTTLS", 122 | "StartTLS", 123 | "Extension AUTH", 124 | "Auth", 125 | "Mail " + testFrom, 126 | "Rcpt " + testTo1, 127 | "Rcpt " + testTo2, 128 | "Data", 129 | "Write message", 130 | "Close writer", 131 | "Quit", 132 | "Close", 133 | }) 134 | 135 | if OpportunisticStartTLS != 0 { 136 | t.Errorf("OpportunisticStartTLS: expected 0, got %d", 137 | OpportunisticStartTLS) 138 | } 139 | } 140 | 141 | func TestDialerOpportunisticStartTLSUnsupported(t *testing.T) { 142 | d := NewDialer(testHost, testPort, "user", "pwd") 143 | d.StartTLSPolicy = OpportunisticStartTLS 144 | testSendMailStartTLSUnsupported(t, d, []string{ 145 | "Extension STARTTLS", 146 | "Extension AUTH", 147 | "Auth", 148 | "Mail " + testFrom, 149 | "Rcpt " + testTo1, 150 | "Rcpt " + testTo2, 151 | "Data", 152 | "Write message", 153 | "Close writer", 154 | "Quit", 155 | "Close", 156 | }) 157 | } 158 | 159 | func TestDialerMandatoryStartTLS(t *testing.T) { 160 | d := NewDialer(testHost, testPort, "user", "pwd") 161 | d.StartTLSPolicy = MandatoryStartTLS 162 | testSendMail(t, d, []string{ 163 | "Extension STARTTLS", 164 | "StartTLS", 165 | "Extension AUTH", 166 | "Auth", 167 | "Mail " + testFrom, 168 | "Rcpt " + testTo1, 169 | "Rcpt " + testTo2, 170 | "Data", 171 | "Write message", 172 | "Close writer", 173 | "Quit", 174 | "Close", 175 | }) 176 | } 177 | 178 | func TestDialerMandatoryStartTLSUnsupported(t *testing.T) { 179 | d := NewDialer(testHost, testPort, "user", "pwd") 180 | d.StartTLSPolicy = MandatoryStartTLS 181 | 182 | testClient := &mockClient{ 183 | t: t, 184 | addr: addr(d.Host, d.Port), 185 | config: d.TLSConfig, 186 | startTLS: false, 187 | timeout: true, 188 | } 189 | 190 | err := doTestSendMail(t, d, testClient, []string{ 191 | "Extension STARTTLS", 192 | }) 193 | 194 | if _, ok := err.(StartTLSUnsupportedError); !ok { 195 | t.Errorf("expected StartTLSUnsupportedError, but got: %s", 196 | reflect.TypeOf(err).Name()) 197 | } 198 | 199 | expected := "gomail: MandatoryStartTLS required, " + 200 | "but SMTP server does not support STARTTLS" 201 | if err.Error() != expected { 202 | t.Errorf("expected %s, but got: %s", expected, err) 203 | } 204 | } 205 | 206 | func TestDialerNoAuth(t *testing.T) { 207 | d := &Dialer{ 208 | Host: testHost, 209 | Port: testPort, 210 | } 211 | testSendMail(t, d, []string{ 212 | "Extension STARTTLS", 213 | "StartTLS", 214 | "Mail " + testFrom, 215 | "Rcpt " + testTo1, 216 | "Rcpt " + testTo2, 217 | "Data", 218 | "Write message", 219 | "Close writer", 220 | "Quit", 221 | "Close", 222 | }) 223 | } 224 | 225 | func TestDialerTimeout(t *testing.T) { 226 | d := &Dialer{ 227 | Host: testHost, 228 | Port: testPort, 229 | RetryFailure: true, 230 | } 231 | testSendMailTimeout(t, d, []string{ 232 | "Extension STARTTLS", 233 | "StartTLS", 234 | "Mail " + testFrom, 235 | "Extension STARTTLS", 236 | "StartTLS", 237 | "Mail " + testFrom, 238 | "Rcpt " + testTo1, 239 | "Rcpt " + testTo2, 240 | "Data", 241 | "Write message", 242 | "Close writer", 243 | "Quit", 244 | "Close", 245 | }) 246 | } 247 | 248 | func TestDialerTimeoutNoRetry(t *testing.T) { 249 | d := &Dialer{ 250 | Host: testHost, 251 | Port: testPort, 252 | RetryFailure: false, 253 | } 254 | testClient := &mockClient{ 255 | t: t, 256 | addr: addr(d.Host, d.Port), 257 | config: d.TLSConfig, 258 | startTLS: true, 259 | timeout: true, 260 | } 261 | 262 | err := doTestSendMail(t, d, testClient, []string{ 263 | "Extension STARTTLS", 264 | "StartTLS", 265 | "Mail " + testFrom, 266 | "Quit", 267 | }) 268 | 269 | if err.Error() != "gomail: could not send email 1: EOF" { 270 | t.Error("expected to have got EOF, but got:", err) 271 | } 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 | if err := doTestSendMail(t, d, testClient, want); err != nil { 382 | t.Error(err) 383 | } 384 | } 385 | 386 | func testSendMailStartTLSUnsupported(t *testing.T, d *Dialer, want []string) { 387 | testClient := &mockClient{ 388 | t: t, 389 | addr: addr(d.Host, d.Port), 390 | config: d.TLSConfig, 391 | startTLS: false, 392 | timeout: false, 393 | } 394 | 395 | if err := doTestSendMail(t, d, testClient, want); err != nil { 396 | t.Error(err) 397 | } 398 | } 399 | 400 | func testSendMailTimeout(t *testing.T, d *Dialer, want []string) { 401 | testClient := &mockClient{ 402 | t: t, 403 | addr: addr(d.Host, d.Port), 404 | config: d.TLSConfig, 405 | startTLS: true, 406 | timeout: true, 407 | } 408 | 409 | if err := doTestSendMail(t, d, testClient, want); err != nil { 410 | t.Error(err) 411 | } 412 | } 413 | 414 | func doTestSendMail(t *testing.T, d *Dialer, testClient *mockClient, want []string) error { 415 | testClient.want = want 416 | 417 | NetDialTimeout = func(network, address string, d time.Duration) (net.Conn, error) { 418 | if network != "tcp" { 419 | t.Errorf("Invalid network, got %q, want tcp", network) 420 | } 421 | if address != testClient.addr { 422 | t.Errorf("Invalid address, got %q, want %q", 423 | address, testClient.addr) 424 | } 425 | return testConn, nil 426 | } 427 | 428 | tlsClient = func(conn net.Conn, config *tls.Config) *tls.Conn { 429 | if conn != testConn { 430 | t.Errorf("Invalid conn, got %#v, want %#v", conn, testConn) 431 | } 432 | assertConfig(t, config, testClient.config) 433 | return testTLSConn 434 | } 435 | 436 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { 437 | if host != testHost { 438 | t.Errorf("Invalid host, got %q, want %q", host, testHost) 439 | } 440 | return testClient, nil 441 | } 442 | 443 | return d.DialAndSend(getTestMessage()) 444 | } 445 | 446 | func assertConfig(t *testing.T, got, want *tls.Config) { 447 | if want == nil { 448 | want = &tls.Config{ServerName: testHost} 449 | } 450 | if got.ServerName != want.ServerName { 451 | t.Errorf("Invalid field ServerName in config, got %q, want %q", got.ServerName, want.ServerName) 452 | } 453 | if got.InsecureSkipVerify != want.InsecureSkipVerify { 454 | t.Errorf("Invalid field InsecureSkipVerify in config, got %v, want %v", got.InsecureSkipVerify, want.InsecureSkipVerify) 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /writeto.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "encoding/base64" 5 | "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 | var subWriter io.Writer 262 | if w.depth == 0 { 263 | w.writeString("\r\n") 264 | subWriter = w.w 265 | } else { 266 | subWriter = w.partWriter 267 | } 268 | 269 | if enc == Base64 { 270 | wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) 271 | w.err = f(wc) 272 | wc.Close() 273 | } else if enc == Unencoded { 274 | w.err = f(subWriter) 275 | } else { 276 | wc := newQPWriter(subWriter) 277 | w.err = f(wc) 278 | wc.Close() 279 | } 280 | } 281 | 282 | // As required by RFC 2045, 6.7. (page 21) for quoted-printable, and 283 | // RFC 2045, 6.8. (page 25) for base64. 284 | const maxLineLen = 76 285 | 286 | // base64LineWriter limits text encoded in base64 to 76 characters per line 287 | type base64LineWriter struct { 288 | w io.Writer 289 | lineLen int 290 | } 291 | 292 | func newBase64LineWriter(w io.Writer) *base64LineWriter { 293 | return &base64LineWriter{w: w} 294 | } 295 | 296 | func (w *base64LineWriter) Write(p []byte) (int, error) { 297 | n := 0 298 | for len(p)+w.lineLen > maxLineLen { 299 | w.w.Write(p[:maxLineLen-w.lineLen]) 300 | w.w.Write([]byte("\r\n")) 301 | p = p[maxLineLen-w.lineLen:] 302 | n += maxLineLen - w.lineLen 303 | w.lineLen = 0 304 | } 305 | 306 | w.w.Write(p) 307 | w.lineLen += len(p) 308 | 309 | return n + len(p), nil 310 | } 311 | 312 | // Stubbed out for testing. 313 | var now = time.Now 314 | --------------------------------------------------------------------------------