├── .travis.yml ├── doc.go ├── mime.go ├── mime_go14.go ├── CONTRIBUTING.md ├── CHANGELOG.md ├── LICENSE ├── auth.go ├── send_test.go ├── auth_test.go ├── README.md ├── send.go ├── smtp.go ├── example_test.go ├── smtp_test.go ├── writeto.go ├── message.go └── message_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.2 5 | - 1.3 6 | - 1.4 7 | - 1.5 8 | - 1.6 9 | - tip 10 | -------------------------------------------------------------------------------- /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-gomail/gomail 5 | package gomail 6 | -------------------------------------------------------------------------------- /mime.go: -------------------------------------------------------------------------------- 1 | // +build go1.5 2 | 3 | package gomail 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 gomail 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ## [2.0.0] - 2015-09-02 6 | 7 | - Mailer has been removed. It has been replaced by Dialer and Sender. 8 | - `File` type and the `CreateFile` and `OpenFile` functions have been removed. 9 | - `Message.Attach` and `Message.Embed` have a new signature. 10 | - `Message.GetBodyWriter` has been removed. Use `Message.AddAlternativeWriter` 11 | instead. 12 | - `Message.Export` has been removed. `Message.WriteTo` can be used instead. 13 | - `Message.DelHeader` has been removed. 14 | - The `Bcc` header field is no longer sent. It is far more simpler and 15 | efficient: the same message is sent to all recipients instead of sending a 16 | different email to each Bcc address. 17 | - LoginAuth has been removed. `NewPlainDialer` now implements the LOGIN 18 | authentication mechanism when needed. 19 | - Go 1.2 is now required instead of Go 1.3. No external dependency are used when 20 | using Go 1.5. 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 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package gomail 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 | -------------------------------------------------------------------------------- /send_test.go: -------------------------------------------------------------------------------- 1 | package gomail 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | const ( 11 | testTo1 = "to1@example.com" 12 | testTo2 = "to2@example.com" 13 | testFrom = "from@example.com" 14 | testBody = "Test message" 15 | testMsg = "To: " + testTo1 + ", " + testTo2 + "\r\n" + 16 | "From: " + testFrom + "\r\n" + 17 | "Mime-Version: 1.0\r\n" + 18 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 19 | "Content-Type: text/plain; charset=UTF-8\r\n" + 20 | "Content-Transfer-Encoding: quoted-printable\r\n" + 21 | "\r\n" + 22 | testBody 23 | ) 24 | 25 | type mockSender SendFunc 26 | 27 | func (s mockSender) Send(from string, to []string, msg io.WriterTo) error { 28 | return s(from, to, msg) 29 | } 30 | 31 | type mockSendCloser struct { 32 | mockSender 33 | close func() error 34 | } 35 | 36 | func (s *mockSendCloser) Close() error { 37 | return s.close() 38 | } 39 | 40 | func TestSend(t *testing.T) { 41 | s := &mockSendCloser{ 42 | mockSender: stubSend(t, testFrom, []string{testTo1, testTo2}, testMsg), 43 | close: func() error { 44 | t.Error("Close() should not be called in Send()") 45 | return nil 46 | }, 47 | } 48 | if err := Send(s, getTestMessage()); err != nil { 49 | t.Errorf("Send(): %v", err) 50 | } 51 | } 52 | 53 | func getTestMessage() *Message { 54 | m := NewMessage() 55 | m.SetHeader("From", testFrom) 56 | m.SetHeader("To", testTo1, testTo2) 57 | m.SetBody("text/plain", testBody) 58 | 59 | return m 60 | } 61 | 62 | func stubSend(t *testing.T, wantFrom string, wantTo []string, wantBody string) mockSender { 63 | return func(from string, to []string, msg io.WriterTo) error { 64 | if from != wantFrom { 65 | t.Errorf("invalid from, got %q, want %q", from, wantFrom) 66 | } 67 | if !reflect.DeepEqual(to, wantTo) { 68 | t.Errorf("invalid to, got %v, want %v", to, wantTo) 69 | } 70 | 71 | buf := new(bytes.Buffer) 72 | _, err := msg.WriteTo(buf) 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | compareBodies(t, buf.String(), wantBody) 77 | 78 | return nil 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package gomail 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gomail 2 | [![Build Status](https://travis-ci.org/go-gomail/gomail.svg?branch=v2)](https://travis-ci.org/go-gomail/gomail) [![Code Coverage](http://gocover.io/_badge/gopkg.in/gomail.v2)](http://gocover.io/gopkg.in/gomail.v2) [![Documentation](https://godoc.org/gopkg.in/gomail.v2?status.svg)](https://godoc.org/gopkg.in/gomail.v2) 3 | 4 | ## Introduction 5 | 6 | Gomail is a simple and efficient package to send emails. It is well tested and 7 | documented. 8 | 9 | Gomail can only send emails using an SMTP server. But the API is flexible and it 10 | is easy to implement other methods for sending emails using a local Postfix, an 11 | API, etc. 12 | 13 | It is versioned using [gopkg.in](https://gopkg.in) so I promise 14 | there will never be backward incompatible changes within each version. 15 | 16 | It requires Go 1.2 or newer. With Go 1.5, no external dependencies are used. 17 | 18 | 19 | ## Features 20 | 21 | Gomail supports: 22 | - Attachments 23 | - Embedded images 24 | - HTML and text templates 25 | - Automatic encoding of special characters 26 | - SSL and TLS 27 | - Sending multiple emails with the same SMTP connection 28 | 29 | 30 | ## Documentation 31 | 32 | https://godoc.org/gopkg.in/gomail.v2 33 | 34 | 35 | ## Download 36 | 37 | go get gopkg.in/gomail.v2 38 | 39 | 40 | ## Examples 41 | 42 | See the [examples in the documentation](https://godoc.org/gopkg.in/gomail.v2#example-package). 43 | 44 | 45 | ## FAQ 46 | 47 | ### x509: certificate signed by unknown authority 48 | 49 | If you get this error it means the certificate used by the SMTP server is not 50 | considered valid by the client running Gomail. As a quick workaround you can 51 | bypass the verification of the server's certificate chain and host name by using 52 | `SetTLSConfig`: 53 | 54 | package main 55 | 56 | import ( 57 | "crypto/tls" 58 | 59 | "gopkg.in/gomail.v2" 60 | ) 61 | 62 | func main() { 63 | d := gomail.NewDialer("smtp.example.com", 587, "user", "123456") 64 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true} 65 | 66 | // Send emails using d. 67 | } 68 | 69 | Note, however, that this is insecure and should not be used in production. 70 | 71 | 72 | ## Contribute 73 | 74 | Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for 75 | more info. 76 | 77 | 78 | ## Change log 79 | 80 | See [CHANGELOG.md](CHANGELOG.md). 81 | 82 | 83 | ## License 84 | 85 | [MIT](LICENSE) 86 | 87 | 88 | ## Contact 89 | 90 | You can ask questions on the [Gomail 91 | thread](https://groups.google.com/d/topic/golang-nuts/jMxZHzvvEVg/discussion) 92 | in the Go mailing-list. 93 | -------------------------------------------------------------------------------- /send.go: -------------------------------------------------------------------------------- 1 | package gomail 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "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 fmt.Errorf("gomail: could not send email %d: %v", i+1, err) 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 := mail.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 | -------------------------------------------------------------------------------- /smtp.go: -------------------------------------------------------------------------------- 1 | package gomail 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 | // TSLConfig represents the TLS configuration used for the TLS (when the 31 | // STARTTLS extension is used) or SSL connection. 32 | TLSConfig *tls.Config 33 | // LocalName is the hostname sent to the SMTP server with the HELO command. 34 | // By default, "localhost" is sent. 35 | LocalName string 36 | } 37 | 38 | // NewDialer returns a new SMTP Dialer. The given parameters are used to connect 39 | // to the SMTP server. 40 | func NewDialer(host string, port int, username, password string) *Dialer { 41 | return &Dialer{ 42 | Host: host, 43 | Port: port, 44 | Username: username, 45 | Password: password, 46 | SSL: port == 465, 47 | } 48 | } 49 | 50 | // NewPlainDialer returns a new SMTP Dialer. The given parameters are used to 51 | // connect to the SMTP server. 52 | // 53 | // Deprecated: Use NewDialer instead. 54 | func NewPlainDialer(host string, port int, username, password string) *Dialer { 55 | return NewDialer(host, port, username, password) 56 | } 57 | 58 | // Dial dials and authenticates to an SMTP server. The returned SendCloser 59 | // should be closed when done using it. 60 | func (d *Dialer) Dial() (SendCloser, error) { 61 | conn, err := netDialTimeout("tcp", addr(d.Host, d.Port), 10*time.Second) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | if d.SSL { 67 | conn = tlsClient(conn, d.tlsConfig()) 68 | } 69 | 70 | c, err := smtpNewClient(conn, d.Host) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | if d.LocalName != "" { 76 | if err := c.Hello(d.LocalName); err != nil { 77 | return nil, err 78 | } 79 | } 80 | 81 | if !d.SSL { 82 | if ok, _ := c.Extension("STARTTLS"); ok { 83 | if err := c.StartTLS(d.tlsConfig()); err != nil { 84 | c.Close() 85 | return nil, err 86 | } 87 | } 88 | } 89 | 90 | if d.Auth == nil && d.Username != "" { 91 | if ok, auths := c.Extension("AUTH"); ok { 92 | if strings.Contains(auths, "CRAM-MD5") { 93 | d.Auth = smtp.CRAMMD5Auth(d.Username, d.Password) 94 | } else if strings.Contains(auths, "LOGIN") && 95 | !strings.Contains(auths, "PLAIN") { 96 | d.Auth = &loginAuth{ 97 | username: d.Username, 98 | password: d.Password, 99 | host: d.Host, 100 | } 101 | } else { 102 | d.Auth = smtp.PlainAuth("", d.Username, d.Password, d.Host) 103 | } 104 | } 105 | } 106 | 107 | if d.Auth != nil { 108 | if err = c.Auth(d.Auth); err != nil { 109 | c.Close() 110 | return nil, err 111 | } 112 | } 113 | 114 | return &smtpSender{c, d}, nil 115 | } 116 | 117 | func (d *Dialer) tlsConfig() *tls.Config { 118 | if d.TLSConfig == nil { 119 | return &tls.Config{ServerName: d.Host} 120 | } 121 | return d.TLSConfig 122 | } 123 | 124 | func addr(host string, port int) string { 125 | return fmt.Sprintf("%s:%d", host, port) 126 | } 127 | 128 | // DialAndSend opens a connection to the SMTP server, sends the given emails and 129 | // closes the connection. 130 | func (d *Dialer) DialAndSend(m ...*Message) error { 131 | s, err := d.Dial() 132 | if err != nil { 133 | return err 134 | } 135 | defer s.Close() 136 | 137 | return Send(s, m...) 138 | } 139 | 140 | type smtpSender struct { 141 | smtpClient 142 | d *Dialer 143 | } 144 | 145 | func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error { 146 | if err := c.Mail(from); err != nil { 147 | if err == io.EOF { 148 | // This is probably due to a timeout, so reconnect and try again. 149 | sc, derr := c.d.Dial() 150 | if derr == nil { 151 | if s, ok := sc.(*smtpSender); ok { 152 | *c = *s 153 | return c.Send(from, to, msg) 154 | } 155 | } 156 | } 157 | return err 158 | } 159 | 160 | for _, addr := range to { 161 | if err := c.Rcpt(addr); err != nil { 162 | return err 163 | } 164 | } 165 | 166 | w, err := c.Data() 167 | if err != nil { 168 | return err 169 | } 170 | 171 | if _, err = msg.WriteTo(w); err != nil { 172 | w.Close() 173 | return err 174 | } 175 | 176 | return w.Close() 177 | } 178 | 179 | func (c *smtpSender) Close() error { 180 | return c.Quit() 181 | } 182 | 183 | // Stubbed out for tests. 184 | var ( 185 | netDialTimeout = net.DialTimeout 186 | tlsClient = tls.Client 187 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { 188 | return smtp.NewClient(conn, host) 189 | } 190 | ) 191 | 192 | type smtpClient interface { 193 | Hello(string) error 194 | Extension(string) (bool, string) 195 | StartTLS(*tls.Config) error 196 | Auth(smtp.Auth) error 197 | Mail(string) error 198 | Rcpt(string) error 199 | Data() (io.WriteCloser, error) 200 | Quit() error 201 | Close() error 202 | } 203 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package gomail_test 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io" 7 | "log" 8 | "time" 9 | 10 | "gopkg.in/gomail.v2" 11 | ) 12 | 13 | func Example() { 14 | m := gomail.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 := gomail.NewDialer("smtp.example.com", 587, "user", "123456") 23 | 24 | // Send the email to Bob, Cora and Dan. 25 | if err := d.DialAndSend(m); err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | // A daemon that listens to a channel and sends all incoming messages. 31 | func Example_daemon() { 32 | ch := make(chan *gomail.Message) 33 | 34 | go func() { 35 | d := gomail.NewDialer("smtp.example.com", 587, "user", "123456") 36 | 37 | var s gomail.SendCloser 38 | var err error 39 | open := false 40 | for { 41 | select { 42 | case m, ok := <-ch: 43 | if !ok { 44 | return 45 | } 46 | if !open { 47 | if s, err = d.Dial(); err != nil { 48 | panic(err) 49 | } 50 | open = true 51 | } 52 | if err := gomail.Send(s, m); err != nil { 53 | log.Print(err) 54 | } 55 | // Close the connection to the SMTP server if no email was sent in 56 | // the last 30 seconds. 57 | case <-time.After(30 * time.Second): 58 | if open { 59 | if err := s.Close(); err != nil { 60 | panic(err) 61 | } 62 | open = false 63 | } 64 | } 65 | } 66 | }() 67 | 68 | // Use the channel in your program to send emails. 69 | 70 | // Close the channel to stop the mail daemon. 71 | close(ch) 72 | } 73 | 74 | // Efficiently send a customized newsletter to a list of recipients. 75 | func Example_newsletter() { 76 | // The list of recipients. 77 | var list []struct { 78 | Name string 79 | Address string 80 | } 81 | 82 | d := gomail.NewDialer("smtp.example.com", 587, "user", "123456") 83 | s, err := d.Dial() 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | m := gomail.NewMessage() 89 | for _, r := range list { 90 | m.SetHeader("From", "no-reply@example.com") 91 | m.SetAddressHeader("To", r.Address, r.Name) 92 | m.SetHeader("Subject", "Newsletter #1") 93 | m.SetBody("text/html", fmt.Sprintf("Hello %s!", r.Name)) 94 | 95 | if err := gomail.Send(s, m); err != nil { 96 | log.Printf("Could not send email to %q: %v", r.Address, err) 97 | } 98 | m.Reset() 99 | } 100 | } 101 | 102 | // Send an email using a local SMTP server. 103 | func Example_noAuth() { 104 | m := gomail.NewMessage() 105 | m.SetHeader("From", "from@example.com") 106 | m.SetHeader("To", "to@example.com") 107 | m.SetHeader("Subject", "Hello!") 108 | m.SetBody("text/plain", "Hello!") 109 | 110 | d := gomail.Dialer{Host: "localhost", Port: 587} 111 | if err := d.DialAndSend(m); err != nil { 112 | panic(err) 113 | } 114 | } 115 | 116 | // Send an email using an API or postfix. 117 | func Example_noSMTP() { 118 | m := gomail.NewMessage() 119 | m.SetHeader("From", "from@example.com") 120 | m.SetHeader("To", "to@example.com") 121 | m.SetHeader("Subject", "Hello!") 122 | m.SetBody("text/plain", "Hello!") 123 | 124 | s := gomail.SendFunc(func(from string, to []string, msg io.WriterTo) error { 125 | // Implements you email-sending function, for example by calling 126 | // an API, or running postfix, etc. 127 | fmt.Println("From:", from) 128 | fmt.Println("To:", to) 129 | return nil 130 | }) 131 | 132 | if err := gomail.Send(s, m); err != nil { 133 | panic(err) 134 | } 135 | // Output: 136 | // From: from@example.com 137 | // To: [to@example.com] 138 | } 139 | 140 | var m *gomail.Message 141 | 142 | func ExampleSetCopyFunc() { 143 | m.Attach("foo.txt", gomail.SetCopyFunc(func(w io.Writer) error { 144 | _, err := w.Write([]byte("Content of foo.txt")) 145 | return err 146 | })) 147 | } 148 | 149 | func ExampleSetHeader() { 150 | h := map[string][]string{"Content-ID": {""}} 151 | m.Attach("foo.jpg", gomail.SetHeader(h)) 152 | } 153 | 154 | func ExampleRename() { 155 | m.Attach("/tmp/0000146.jpg", gomail.Rename("picture.jpg")) 156 | } 157 | 158 | func ExampleMessage_AddAlternative() { 159 | m.SetBody("text/plain", "Hello!") 160 | m.AddAlternative("text/html", "

Hello!

") 161 | } 162 | 163 | func ExampleMessage_AddAlternativeWriter() { 164 | t := template.Must(template.New("example").Parse("Hello {{.}}!")) 165 | m.AddAlternativeWriter("text/plain", func(w io.Writer) error { 166 | return t.Execute(w, "Bob") 167 | }) 168 | } 169 | 170 | func ExampleMessage_Attach() { 171 | m.Attach("/tmp/image.jpg") 172 | } 173 | 174 | func ExampleMessage_Embed() { 175 | m.Embed("/tmp/image.jpg") 176 | m.SetBody("text/html", `My image`) 177 | } 178 | 179 | func ExampleMessage_FormatAddress() { 180 | m.SetHeader("To", m.FormatAddress("bob@example.com", "Bob"), m.FormatAddress("cora@example.com", "Cora")) 181 | } 182 | 183 | func ExampleMessage_FormatDate() { 184 | m.SetHeaders(map[string][]string{ 185 | "X-Date": {m.FormatDate(time.Now())}, 186 | }) 187 | } 188 | 189 | func ExampleMessage_SetAddressHeader() { 190 | m.SetAddressHeader("To", "bob@example.com", "Bob") 191 | } 192 | 193 | func ExampleMessage_SetBody() { 194 | m.SetBody("text/plain", "Hello!") 195 | } 196 | 197 | func ExampleMessage_SetDateHeader() { 198 | m.SetDateHeader("X-Date", time.Now()) 199 | } 200 | 201 | func ExampleMessage_SetHeader() { 202 | m.SetHeader("Subject", "Hello!") 203 | } 204 | 205 | func ExampleMessage_SetHeaders() { 206 | m.SetHeaders(map[string][]string{ 207 | "From": {m.FormatAddress("alex@example.com", "Alex")}, 208 | "To": {"bob@example.com", "cora@example.com"}, 209 | "Subject": {"Hello"}, 210 | }) 211 | } 212 | 213 | func ExampleSetCharset() { 214 | m = gomail.NewMessage(gomail.SetCharset("ISO-8859-1")) 215 | } 216 | 217 | func ExampleSetEncoding() { 218 | m = gomail.NewMessage(gomail.SetEncoding(gomail.Base64)) 219 | } 220 | 221 | func ExampleSetPartEncoding() { 222 | m.SetBody("text/plain", "Hello!", gomail.SetPartEncoding(gomail.Unencoded)) 223 | } 224 | -------------------------------------------------------------------------------- /smtp_test.go: -------------------------------------------------------------------------------- 1 | package gomail 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.Conn{} 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 TestDialerNoAuth(t *testing.T) { 101 | d := &Dialer{ 102 | Host: testHost, 103 | Port: testPort, 104 | } 105 | testSendMail(t, d, []string{ 106 | "Extension STARTTLS", 107 | "StartTLS", 108 | "Mail " + testFrom, 109 | "Rcpt " + testTo1, 110 | "Rcpt " + testTo2, 111 | "Data", 112 | "Write message", 113 | "Close writer", 114 | "Quit", 115 | "Close", 116 | }) 117 | } 118 | 119 | func TestDialerTimeout(t *testing.T) { 120 | d := &Dialer{ 121 | Host: testHost, 122 | Port: testPort, 123 | } 124 | testSendMailTimeout(t, d, []string{ 125 | "Extension STARTTLS", 126 | "StartTLS", 127 | "Mail " + testFrom, 128 | "Extension STARTTLS", 129 | "StartTLS", 130 | "Mail " + testFrom, 131 | "Rcpt " + testTo1, 132 | "Rcpt " + testTo2, 133 | "Data", 134 | "Write message", 135 | "Close writer", 136 | "Quit", 137 | "Close", 138 | }) 139 | } 140 | 141 | type mockClient struct { 142 | t *testing.T 143 | i int 144 | want []string 145 | addr string 146 | config *tls.Config 147 | timeout bool 148 | } 149 | 150 | func (c *mockClient) Hello(localName string) error { 151 | c.do("Hello " + localName) 152 | return nil 153 | } 154 | 155 | func (c *mockClient) Extension(ext string) (bool, string) { 156 | c.do("Extension " + ext) 157 | return true, "" 158 | } 159 | 160 | func (c *mockClient) StartTLS(config *tls.Config) error { 161 | assertConfig(c.t, config, c.config) 162 | c.do("StartTLS") 163 | return nil 164 | } 165 | 166 | func (c *mockClient) Auth(a smtp.Auth) error { 167 | if !reflect.DeepEqual(a, testAuth) { 168 | c.t.Errorf("Invalid auth, got %#v, want %#v", a, testAuth) 169 | } 170 | c.do("Auth") 171 | return nil 172 | } 173 | 174 | func (c *mockClient) Mail(from string) error { 175 | c.do("Mail " + from) 176 | if c.timeout { 177 | c.timeout = false 178 | return io.EOF 179 | } 180 | return nil 181 | } 182 | 183 | func (c *mockClient) Rcpt(to string) error { 184 | c.do("Rcpt " + to) 185 | return nil 186 | } 187 | 188 | func (c *mockClient) Data() (io.WriteCloser, error) { 189 | c.do("Data") 190 | return &mockWriter{c: c, want: testMsg}, nil 191 | } 192 | 193 | func (c *mockClient) Quit() error { 194 | c.do("Quit") 195 | return nil 196 | } 197 | 198 | func (c *mockClient) Close() error { 199 | c.do("Close") 200 | return nil 201 | } 202 | 203 | func (c *mockClient) do(cmd string) { 204 | if c.i >= len(c.want) { 205 | c.t.Fatalf("Invalid command %q", cmd) 206 | } 207 | 208 | if cmd != c.want[c.i] { 209 | c.t.Fatalf("Invalid command, got %q, want %q", cmd, c.want[c.i]) 210 | } 211 | c.i++ 212 | } 213 | 214 | type mockWriter struct { 215 | want string 216 | c *mockClient 217 | buf bytes.Buffer 218 | } 219 | 220 | func (w *mockWriter) Write(p []byte) (int, error) { 221 | if w.buf.Len() == 0 { 222 | w.c.do("Write message") 223 | } 224 | w.buf.Write(p) 225 | return len(p), nil 226 | } 227 | 228 | func (w *mockWriter) Close() error { 229 | compareBodies(w.c.t, w.buf.String(), w.want) 230 | w.c.do("Close writer") 231 | return nil 232 | } 233 | 234 | func testSendMail(t *testing.T, d *Dialer, want []string) { 235 | doTestSendMail(t, d, want, false) 236 | } 237 | 238 | func testSendMailTimeout(t *testing.T, d *Dialer, want []string) { 239 | doTestSendMail(t, d, want, true) 240 | } 241 | 242 | func doTestSendMail(t *testing.T, d *Dialer, want []string, timeout bool) { 243 | testClient := &mockClient{ 244 | t: t, 245 | want: want, 246 | addr: addr(d.Host, d.Port), 247 | config: d.TLSConfig, 248 | timeout: timeout, 249 | } 250 | 251 | netDialTimeout = func(network, address string, d time.Duration) (net.Conn, error) { 252 | if network != "tcp" { 253 | t.Errorf("Invalid network, got %q, want tcp", network) 254 | } 255 | if address != testClient.addr { 256 | t.Errorf("Invalid address, got %q, want %q", 257 | address, testClient.addr) 258 | } 259 | return testConn, nil 260 | } 261 | 262 | tlsClient = func(conn net.Conn, config *tls.Config) *tls.Conn { 263 | if conn != testConn { 264 | t.Errorf("Invalid conn, got %#v, want %#v", conn, testConn) 265 | } 266 | assertConfig(t, config, testClient.config) 267 | return testTLSConn 268 | } 269 | 270 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { 271 | if host != testHost { 272 | t.Errorf("Invalid host, got %q, want %q", host, testHost) 273 | } 274 | return testClient, nil 275 | } 276 | 277 | if err := d.DialAndSend(getTestMessage()); err != nil { 278 | t.Error(err) 279 | } 280 | } 281 | 282 | func assertConfig(t *testing.T, got, want *tls.Config) { 283 | if want == nil { 284 | want = &tls.Config{ServerName: testHost} 285 | } 286 | if got.ServerName != want.ServerName { 287 | t.Errorf("Invalid field ServerName in config, got %q, want %q", got.ServerName, want.ServerName) 288 | } 289 | if got.InsecureSkipVerify != want.InsecureSkipVerify { 290 | t.Errorf("Invalid field InsecureSkipVerify in config, got %v, want %v", got.InsecureSkipVerify, want.InsecureSkipVerify) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /writeto.go: -------------------------------------------------------------------------------- 1 | package gomail 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") 32 | } 33 | 34 | if m.hasRelatedPart() { 35 | w.openMultipart("related") 36 | } 37 | 38 | if m.hasAlternativePart() { 39 | w.openMultipart("alternative") 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 string) { 81 | mw := multipart.NewWriter(w) 82 | contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary() 83 | w.writers[w.depth] = mw 84 | 85 | if w.depth == 0 { 86 | w.writeHeader("Content-Type", contentType) 87 | w.writeString("\r\n") 88 | } else { 89 | w.createPart(map[string][]string{ 90 | "Content-Type": {contentType}, 91 | }) 92 | } 93 | w.depth++ 94 | } 95 | 96 | func (w *messageWriter) createPart(h map[string][]string) { 97 | w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h) 98 | } 99 | 100 | func (w *messageWriter) closeMultipart() { 101 | if w.depth > 0 { 102 | w.writers[w.depth-1].Close() 103 | w.depth-- 104 | } 105 | } 106 | 107 | func (w *messageWriter) writePart(p *part, charset string) { 108 | w.writeHeaders(map[string][]string{ 109 | "Content-Type": {p.contentType + "; charset=" + charset}, 110 | "Content-Transfer-Encoding": {string(p.encoding)}, 111 | }) 112 | w.writeBody(p.copier, p.encoding) 113 | } 114 | 115 | func (w *messageWriter) addFiles(files []*file, isAttachment bool) { 116 | for _, f := range files { 117 | if _, ok := f.Header["Content-Type"]; !ok { 118 | mediaType := mime.TypeByExtension(filepath.Ext(f.Name)) 119 | if mediaType == "" { 120 | mediaType = "application/octet-stream" 121 | } 122 | f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`) 123 | } 124 | 125 | if _, ok := f.Header["Content-Transfer-Encoding"]; !ok { 126 | f.setHeader("Content-Transfer-Encoding", string(Base64)) 127 | } 128 | 129 | if _, ok := f.Header["Content-Disposition"]; !ok { 130 | var disp string 131 | if isAttachment { 132 | disp = "attachment" 133 | } else { 134 | disp = "inline" 135 | } 136 | f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`) 137 | } 138 | 139 | if !isAttachment { 140 | if _, ok := f.Header["Content-ID"]; !ok { 141 | f.setHeader("Content-ID", "<"+f.Name+">") 142 | } 143 | } 144 | w.writeHeaders(f.Header) 145 | w.writeBody(f.CopyFunc, Base64) 146 | } 147 | } 148 | 149 | func (w *messageWriter) Write(p []byte) (int, error) { 150 | if w.err != nil { 151 | return 0, errors.New("gomail: cannot write as writer is in error") 152 | } 153 | 154 | var n int 155 | n, w.err = w.w.Write(p) 156 | w.n += int64(n) 157 | return n, w.err 158 | } 159 | 160 | func (w *messageWriter) writeString(s string) { 161 | n, _ := io.WriteString(w.w, s) 162 | w.n += int64(n) 163 | } 164 | 165 | func (w *messageWriter) writeHeader(k string, v ...string) { 166 | w.writeString(k) 167 | if len(v) == 0 { 168 | w.writeString(":\r\n") 169 | return 170 | } 171 | w.writeString(": ") 172 | 173 | // Max header line length is 78 characters in RFC 5322 and 76 characters 174 | // in RFC 2047. So for the sake of simplicity we use the 76 characters 175 | // limit. 176 | charsLeft := 76 - len(k) - len(": ") 177 | 178 | for i, s := range v { 179 | // If the line is already too long, insert a newline right away. 180 | if charsLeft < 1 { 181 | if i == 0 { 182 | w.writeString("\r\n ") 183 | } else { 184 | w.writeString(",\r\n ") 185 | } 186 | charsLeft = 75 187 | } else if i != 0 { 188 | w.writeString(", ") 189 | charsLeft -= 2 190 | } 191 | 192 | // While the header content is too long, fold it by inserting a newline. 193 | for len(s) > charsLeft { 194 | s = w.writeLine(s, charsLeft) 195 | charsLeft = 75 196 | } 197 | w.writeString(s) 198 | if i := lastIndexByte(s, '\n'); i != -1 { 199 | charsLeft = 75 - (len(s) - i - 1) 200 | } else { 201 | charsLeft -= len(s) 202 | } 203 | } 204 | w.writeString("\r\n") 205 | } 206 | 207 | func (w *messageWriter) writeLine(s string, charsLeft int) string { 208 | // If there is already a newline before the limit. Write the line. 209 | if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft { 210 | w.writeString(s[:i+1]) 211 | return s[i+1:] 212 | } 213 | 214 | for i := charsLeft - 1; i >= 0; i-- { 215 | if s[i] == ' ' { 216 | w.writeString(s[:i]) 217 | w.writeString("\r\n ") 218 | return s[i+1:] 219 | } 220 | } 221 | 222 | // We could not insert a newline cleanly so look for a space or a newline 223 | // even if it is after the limit. 224 | for i := 75; i < len(s); i++ { 225 | if s[i] == ' ' { 226 | w.writeString(s[:i]) 227 | w.writeString("\r\n ") 228 | return s[i+1:] 229 | } 230 | if s[i] == '\n' { 231 | w.writeString(s[:i+1]) 232 | return s[i+1:] 233 | } 234 | } 235 | 236 | // Too bad, no space or newline in the whole string. Just write everything. 237 | w.writeString(s) 238 | return "" 239 | } 240 | 241 | func (w *messageWriter) writeHeaders(h map[string][]string) { 242 | if w.depth == 0 { 243 | for k, v := range h { 244 | if k != "Bcc" { 245 | w.writeHeader(k, v...) 246 | } 247 | } 248 | } else { 249 | w.createPart(h) 250 | } 251 | } 252 | 253 | func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) { 254 | var subWriter io.Writer 255 | if w.depth == 0 { 256 | w.writeString("\r\n") 257 | subWriter = w.w 258 | } else { 259 | subWriter = w.partWriter 260 | } 261 | 262 | if enc == Base64 { 263 | wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) 264 | w.err = f(wc) 265 | wc.Close() 266 | } else if enc == Unencoded { 267 | w.err = f(subWriter) 268 | } else { 269 | wc := newQPWriter(subWriter) 270 | w.err = f(wc) 271 | wc.Close() 272 | } 273 | } 274 | 275 | // As required by RFC 2045, 6.7. (page 21) for quoted-printable, and 276 | // RFC 2045, 6.8. (page 25) for base64. 277 | const maxLineLen = 76 278 | 279 | // base64LineWriter limits text encoded in base64 to 76 characters per line 280 | type base64LineWriter struct { 281 | w io.Writer 282 | lineLen int 283 | } 284 | 285 | func newBase64LineWriter(w io.Writer) *base64LineWriter { 286 | return &base64LineWriter{w: w} 287 | } 288 | 289 | func (w *base64LineWriter) Write(p []byte) (int, error) { 290 | n := 0 291 | for len(p)+w.lineLen > maxLineLen { 292 | w.w.Write(p[:maxLineLen-w.lineLen]) 293 | w.w.Write([]byte("\r\n")) 294 | p = p[maxLineLen-w.lineLen:] 295 | n += maxLineLen - w.lineLen 296 | w.lineLen = 0 297 | } 298 | 299 | w.w.Write(p) 300 | w.lineLen += len(p) 301 | 302 | return n + len(p), nil 303 | } 304 | 305 | // Stubbed out for testing. 306 | var now = time.Now 307 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package gomail 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 | } 22 | 23 | type header map[string][]string 24 | 25 | type part struct { 26 | contentType string 27 | copier func(io.Writer) error 28 | encoding Encoding 29 | } 30 | 31 | // NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding 32 | // by default. 33 | func NewMessage(settings ...MessageSetting) *Message { 34 | m := &Message{ 35 | header: make(header), 36 | charset: "UTF-8", 37 | encoding: QuotedPrintable, 38 | } 39 | 40 | m.applySettings(settings) 41 | 42 | if m.encoding == Base64 { 43 | m.hEncoder = bEncoding 44 | } else { 45 | m.hEncoder = qEncoding 46 | } 47 | 48 | return m 49 | } 50 | 51 | // Reset resets the message so it can be reused. The message keeps its previous 52 | // settings so it is in the same state that after a call to NewMessage. 53 | func (m *Message) Reset() { 54 | for k := range m.header { 55 | delete(m.header, k) 56 | } 57 | m.parts = nil 58 | m.attachments = nil 59 | m.embedded = nil 60 | } 61 | 62 | func (m *Message) applySettings(settings []MessageSetting) { 63 | for _, s := range settings { 64 | s(m) 65 | } 66 | } 67 | 68 | // A MessageSetting can be used as an argument in NewMessage to configure an 69 | // email. 70 | type MessageSetting func(m *Message) 71 | 72 | // SetCharset is a message setting to set the charset of the email. 73 | func SetCharset(charset string) MessageSetting { 74 | return func(m *Message) { 75 | m.charset = charset 76 | } 77 | } 78 | 79 | // SetEncoding is a message setting to set the encoding of the email. 80 | func SetEncoding(enc Encoding) MessageSetting { 81 | return func(m *Message) { 82 | m.encoding = enc 83 | } 84 | } 85 | 86 | // Encoding represents a MIME encoding scheme like quoted-printable or base64. 87 | type Encoding string 88 | 89 | const ( 90 | // QuotedPrintable represents the quoted-printable encoding as defined in 91 | // RFC 2045. 92 | QuotedPrintable Encoding = "quoted-printable" 93 | // Base64 represents the base64 encoding as defined in RFC 2045. 94 | Base64 Encoding = "base64" 95 | // Unencoded can be used to avoid encoding the body of an email. The headers 96 | // will still be encoded using quoted-printable encoding. 97 | Unencoded Encoding = "8bit" 98 | ) 99 | 100 | // SetHeader sets a value to the given header field. 101 | func (m *Message) SetHeader(field string, value ...string) { 102 | m.encodeHeader(value) 103 | m.header[field] = value 104 | } 105 | 106 | func (m *Message) encodeHeader(values []string) { 107 | for i := range values { 108 | values[i] = m.encodeString(values[i]) 109 | } 110 | } 111 | 112 | func (m *Message) encodeString(value string) string { 113 | return m.hEncoder.Encode(m.charset, value) 114 | } 115 | 116 | // SetHeaders sets the message headers. 117 | func (m *Message) SetHeaders(h map[string][]string) { 118 | for k, v := range h { 119 | m.SetHeader(k, v...) 120 | } 121 | } 122 | 123 | // SetAddressHeader sets an address to the given header field. 124 | func (m *Message) SetAddressHeader(field, address, name string) { 125 | m.header[field] = []string{m.FormatAddress(address, name)} 126 | } 127 | 128 | // FormatAddress formats an address and a name as a valid RFC 5322 address. 129 | func (m *Message) FormatAddress(address, name string) string { 130 | if name == "" { 131 | return address 132 | } 133 | 134 | enc := m.encodeString(name) 135 | if enc == name { 136 | m.buf.WriteByte('"') 137 | for i := 0; i < len(name); i++ { 138 | b := name[i] 139 | if b == '\\' || b == '"' { 140 | m.buf.WriteByte('\\') 141 | } 142 | m.buf.WriteByte(b) 143 | } 144 | m.buf.WriteByte('"') 145 | } else if hasSpecials(name) { 146 | m.buf.WriteString(bEncoding.Encode(m.charset, name)) 147 | } else { 148 | m.buf.WriteString(enc) 149 | } 150 | m.buf.WriteString(" <") 151 | m.buf.WriteString(address) 152 | m.buf.WriteByte('>') 153 | 154 | addr := m.buf.String() 155 | m.buf.Reset() 156 | return addr 157 | } 158 | 159 | func hasSpecials(text string) bool { 160 | for i := 0; i < len(text); i++ { 161 | switch c := text[i]; c { 162 | case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"': 163 | return true 164 | } 165 | } 166 | 167 | return false 168 | } 169 | 170 | // SetDateHeader sets a date to the given header field. 171 | func (m *Message) SetDateHeader(field string, date time.Time) { 172 | m.header[field] = []string{m.FormatDate(date)} 173 | } 174 | 175 | // FormatDate formats a date as a valid RFC 5322 date. 176 | func (m *Message) FormatDate(date time.Time) string { 177 | return date.Format(time.RFC1123Z) 178 | } 179 | 180 | // GetHeader gets a header field. 181 | func (m *Message) GetHeader(field string) []string { 182 | return m.header[field] 183 | } 184 | 185 | // SetBody sets the body of the message. It replaces any content previously set 186 | // by SetBody, AddAlternative or AddAlternativeWriter. 187 | func (m *Message) SetBody(contentType, body string, settings ...PartSetting) { 188 | m.parts = []*part{m.newPart(contentType, newCopier(body), settings)} 189 | } 190 | 191 | // AddAlternative adds an alternative part to the message. 192 | // 193 | // It is commonly used to send HTML emails that default to the plain text 194 | // version for backward compatibility. AddAlternative appends the new part to 195 | // the end of the message. So the plain text part should be added before the 196 | // HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative 197 | func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) { 198 | m.AddAlternativeWriter(contentType, newCopier(body), settings...) 199 | } 200 | 201 | func newCopier(s string) func(io.Writer) error { 202 | return func(w io.Writer) error { 203 | _, err := io.WriteString(w, s) 204 | return err 205 | } 206 | } 207 | 208 | // AddAlternativeWriter adds an alternative part to the message. It can be 209 | // useful with the text/template or html/template packages. 210 | func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { 211 | m.parts = append(m.parts, m.newPart(contentType, f, settings)) 212 | } 213 | 214 | func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part { 215 | p := &part{ 216 | contentType: contentType, 217 | copier: f, 218 | encoding: m.encoding, 219 | } 220 | 221 | for _, s := range settings { 222 | s(p) 223 | } 224 | 225 | return p 226 | } 227 | 228 | // A PartSetting can be used as an argument in Message.SetBody, 229 | // Message.AddAlternative or Message.AddAlternativeWriter to configure the part 230 | // added to a message. 231 | type PartSetting func(*part) 232 | 233 | // SetPartEncoding sets the encoding of the part added to the message. By 234 | // default, parts use the same encoding than the message. 235 | func SetPartEncoding(e Encoding) PartSetting { 236 | return PartSetting(func(p *part) { 237 | p.encoding = e 238 | }) 239 | } 240 | 241 | type file struct { 242 | Name string 243 | Header map[string][]string 244 | CopyFunc func(w io.Writer) error 245 | } 246 | 247 | func (f *file) setHeader(field, value string) { 248 | f.Header[field] = []string{value} 249 | } 250 | 251 | // A FileSetting can be used as an argument in Message.Attach or Message.Embed. 252 | type FileSetting func(*file) 253 | 254 | // SetHeader is a file setting to set the MIME header of the message part that 255 | // contains the file content. 256 | // 257 | // Mandatory headers are automatically added if they are not set when sending 258 | // the email. 259 | func SetHeader(h map[string][]string) FileSetting { 260 | return func(f *file) { 261 | for k, v := range h { 262 | f.Header[k] = v 263 | } 264 | } 265 | } 266 | 267 | // Rename is a file setting to set the name of the attachment if the name is 268 | // different than the filename on disk. 269 | func Rename(name string) FileSetting { 270 | return func(f *file) { 271 | f.Name = name 272 | } 273 | } 274 | 275 | // SetCopyFunc is a file setting to replace the function that runs when the 276 | // message is sent. It should copy the content of the file to the io.Writer. 277 | // 278 | // The default copy function opens the file with the given filename, and copy 279 | // its content to the io.Writer. 280 | func SetCopyFunc(f func(io.Writer) error) FileSetting { 281 | return func(fi *file) { 282 | fi.CopyFunc = f 283 | } 284 | } 285 | 286 | func (m *Message) appendFile(list []*file, name string, settings []FileSetting) []*file { 287 | f := &file{ 288 | Name: filepath.Base(name), 289 | Header: make(map[string][]string), 290 | CopyFunc: func(w io.Writer) error { 291 | h, err := os.Open(name) 292 | if err != nil { 293 | return err 294 | } 295 | if _, err := io.Copy(w, h); err != nil { 296 | h.Close() 297 | return err 298 | } 299 | return h.Close() 300 | }, 301 | } 302 | 303 | for _, s := range settings { 304 | s(f) 305 | } 306 | 307 | if list == nil { 308 | return []*file{f} 309 | } 310 | 311 | return append(list, f) 312 | } 313 | 314 | // Attach attaches the files to the email. 315 | func (m *Message) Attach(filename string, settings ...FileSetting) { 316 | m.attachments = m.appendFile(m.attachments, filename, settings) 317 | } 318 | 319 | // Embed embeds the images to the email. 320 | func (m *Message) Embed(filename string, settings ...FileSetting) { 321 | m.embedded = m.appendFile(m.embedded, filename, settings) 322 | } 323 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package gomail 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 TestBodyWriter(t *testing.T) { 204 | m := NewMessage() 205 | m.SetHeader("From", "from@example.com") 206 | m.SetHeader("To", "to@example.com") 207 | m.AddAlternativeWriter("text/plain", func(w io.Writer) error { 208 | _, err := w.Write([]byte("Test message")) 209 | return err 210 | }) 211 | m.AddAlternativeWriter("text/html", func(w io.Writer) error { 212 | _, err := w.Write([]byte("Test HTML")) 213 | return err 214 | }) 215 | 216 | want := &message{ 217 | from: "from@example.com", 218 | to: []string{"to@example.com"}, 219 | content: "From: from@example.com\r\n" + 220 | "To: to@example.com\r\n" + 221 | "Content-Type: multipart/alternative;\r\n" + 222 | " boundary=_BOUNDARY_1_\r\n" + 223 | "\r\n" + 224 | "--_BOUNDARY_1_\r\n" + 225 | "Content-Type: text/plain; charset=UTF-8\r\n" + 226 | "Content-Transfer-Encoding: quoted-printable\r\n" + 227 | "\r\n" + 228 | "Test message\r\n" + 229 | "--_BOUNDARY_1_\r\n" + 230 | "Content-Type: text/html; charset=UTF-8\r\n" + 231 | "Content-Transfer-Encoding: quoted-printable\r\n" + 232 | "\r\n" + 233 | "Test HTML\r\n" + 234 | "--_BOUNDARY_1_--\r\n", 235 | } 236 | 237 | testMessage(t, m, 1, want) 238 | } 239 | 240 | func TestAttachmentOnly(t *testing.T) { 241 | m := NewMessage() 242 | m.SetHeader("From", "from@example.com") 243 | m.SetHeader("To", "to@example.com") 244 | m.Attach(mockCopyFile("/tmp/test.pdf")) 245 | 246 | want := &message{ 247 | from: "from@example.com", 248 | to: []string{"to@example.com"}, 249 | content: "From: from@example.com\r\n" + 250 | "To: to@example.com\r\n" + 251 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 252 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 253 | "Content-Transfer-Encoding: base64\r\n" + 254 | "\r\n" + 255 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")), 256 | } 257 | 258 | testMessage(t, m, 0, want) 259 | } 260 | 261 | func TestAttachment(t *testing.T) { 262 | m := NewMessage() 263 | m.SetHeader("From", "from@example.com") 264 | m.SetHeader("To", "to@example.com") 265 | m.SetBody("text/plain", "Test") 266 | m.Attach(mockCopyFile("/tmp/test.pdf")) 267 | 268 | want := &message{ 269 | from: "from@example.com", 270 | to: []string{"to@example.com"}, 271 | content: "From: from@example.com\r\n" + 272 | "To: to@example.com\r\n" + 273 | "Content-Type: multipart/mixed;\r\n" + 274 | " boundary=_BOUNDARY_1_\r\n" + 275 | "\r\n" + 276 | "--_BOUNDARY_1_\r\n" + 277 | "Content-Type: text/plain; charset=UTF-8\r\n" + 278 | "Content-Transfer-Encoding: quoted-printable\r\n" + 279 | "\r\n" + 280 | "Test\r\n" + 281 | "--_BOUNDARY_1_\r\n" + 282 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 283 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 284 | "Content-Transfer-Encoding: base64\r\n" + 285 | "\r\n" + 286 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 287 | "--_BOUNDARY_1_--\r\n", 288 | } 289 | 290 | testMessage(t, m, 1, want) 291 | } 292 | 293 | func TestRename(t *testing.T) { 294 | m := NewMessage() 295 | m.SetHeader("From", "from@example.com") 296 | m.SetHeader("To", "to@example.com") 297 | m.SetBody("text/plain", "Test") 298 | name, copy := mockCopyFile("/tmp/test.pdf") 299 | rename := Rename("another.pdf") 300 | m.Attach(name, copy, rename) 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: multipart/mixed;\r\n" + 308 | " boundary=_BOUNDARY_1_\r\n" + 309 | "\r\n" + 310 | "--_BOUNDARY_1_\r\n" + 311 | "Content-Type: text/plain; charset=UTF-8\r\n" + 312 | "Content-Transfer-Encoding: quoted-printable\r\n" + 313 | "\r\n" + 314 | "Test\r\n" + 315 | "--_BOUNDARY_1_\r\n" + 316 | "Content-Type: application/pdf; name=\"another.pdf\"\r\n" + 317 | "Content-Disposition: attachment; filename=\"another.pdf\"\r\n" + 318 | "Content-Transfer-Encoding: base64\r\n" + 319 | "\r\n" + 320 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 321 | "--_BOUNDARY_1_--\r\n", 322 | } 323 | 324 | testMessage(t, m, 1, want) 325 | } 326 | 327 | func TestAttachmentsOnly(t *testing.T) { 328 | m := NewMessage() 329 | m.SetHeader("From", "from@example.com") 330 | m.SetHeader("To", "to@example.com") 331 | m.Attach(mockCopyFile("/tmp/test.pdf")) 332 | m.Attach(mockCopyFile("/tmp/test.zip")) 333 | 334 | want := &message{ 335 | from: "from@example.com", 336 | to: []string{"to@example.com"}, 337 | content: "From: from@example.com\r\n" + 338 | "To: to@example.com\r\n" + 339 | "Content-Type: multipart/mixed;\r\n" + 340 | " boundary=_BOUNDARY_1_\r\n" + 341 | "\r\n" + 342 | "--_BOUNDARY_1_\r\n" + 343 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 344 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 345 | "Content-Transfer-Encoding: base64\r\n" + 346 | "\r\n" + 347 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 348 | "--_BOUNDARY_1_\r\n" + 349 | "Content-Type: application/zip; name=\"test.zip\"\r\n" + 350 | "Content-Disposition: attachment; filename=\"test.zip\"\r\n" + 351 | "Content-Transfer-Encoding: base64\r\n" + 352 | "\r\n" + 353 | base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" + 354 | "--_BOUNDARY_1_--\r\n", 355 | } 356 | 357 | testMessage(t, m, 1, want) 358 | } 359 | 360 | func TestAttachments(t *testing.T) { 361 | m := NewMessage() 362 | m.SetHeader("From", "from@example.com") 363 | m.SetHeader("To", "to@example.com") 364 | m.SetBody("text/plain", "Test") 365 | m.Attach(mockCopyFile("/tmp/test.pdf")) 366 | m.Attach(mockCopyFile("/tmp/test.zip")) 367 | 368 | want := &message{ 369 | from: "from@example.com", 370 | to: []string{"to@example.com"}, 371 | content: "From: from@example.com\r\n" + 372 | "To: to@example.com\r\n" + 373 | "Content-Type: multipart/mixed;\r\n" + 374 | " boundary=_BOUNDARY_1_\r\n" + 375 | "\r\n" + 376 | "--_BOUNDARY_1_\r\n" + 377 | "Content-Type: text/plain; charset=UTF-8\r\n" + 378 | "Content-Transfer-Encoding: quoted-printable\r\n" + 379 | "\r\n" + 380 | "Test\r\n" + 381 | "--_BOUNDARY_1_\r\n" + 382 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 383 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 384 | "Content-Transfer-Encoding: base64\r\n" + 385 | "\r\n" + 386 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 387 | "--_BOUNDARY_1_\r\n" + 388 | "Content-Type: application/zip; name=\"test.zip\"\r\n" + 389 | "Content-Disposition: attachment; filename=\"test.zip\"\r\n" + 390 | "Content-Transfer-Encoding: base64\r\n" + 391 | "\r\n" + 392 | base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" + 393 | "--_BOUNDARY_1_--\r\n", 394 | } 395 | 396 | testMessage(t, m, 1, want) 397 | } 398 | 399 | func TestEmbedded(t *testing.T) { 400 | m := NewMessage() 401 | m.SetHeader("From", "from@example.com") 402 | m.SetHeader("To", "to@example.com") 403 | m.Embed(mockCopyFileWithHeader(m, "image1.jpg", map[string][]string{"Content-ID": {""}})) 404 | m.Embed(mockCopyFile("image2.jpg")) 405 | m.SetBody("text/plain", "Test") 406 | 407 | want := &message{ 408 | from: "from@example.com", 409 | to: []string{"to@example.com"}, 410 | content: "From: from@example.com\r\n" + 411 | "To: to@example.com\r\n" + 412 | "Content-Type: multipart/related;\r\n" + 413 | " boundary=_BOUNDARY_1_\r\n" + 414 | "\r\n" + 415 | "--_BOUNDARY_1_\r\n" + 416 | "Content-Type: text/plain; charset=UTF-8\r\n" + 417 | "Content-Transfer-Encoding: quoted-printable\r\n" + 418 | "\r\n" + 419 | "Test\r\n" + 420 | "--_BOUNDARY_1_\r\n" + 421 | "Content-Type: image/jpeg; name=\"image1.jpg\"\r\n" + 422 | "Content-Disposition: inline; filename=\"image1.jpg\"\r\n" + 423 | "Content-ID: \r\n" + 424 | "Content-Transfer-Encoding: base64\r\n" + 425 | "\r\n" + 426 | base64.StdEncoding.EncodeToString([]byte("Content of image1.jpg")) + "\r\n" + 427 | "--_BOUNDARY_1_\r\n" + 428 | "Content-Type: image/jpeg; name=\"image2.jpg\"\r\n" + 429 | "Content-Disposition: inline; filename=\"image2.jpg\"\r\n" + 430 | "Content-ID: \r\n" + 431 | "Content-Transfer-Encoding: base64\r\n" + 432 | "\r\n" + 433 | base64.StdEncoding.EncodeToString([]byte("Content of image2.jpg")) + "\r\n" + 434 | "--_BOUNDARY_1_--\r\n", 435 | } 436 | 437 | testMessage(t, m, 1, want) 438 | } 439 | 440 | func TestFullMessage(t *testing.T) { 441 | m := NewMessage() 442 | m.SetHeader("From", "from@example.com") 443 | m.SetHeader("To", "to@example.com") 444 | m.SetBody("text/plain", "¡Hola, señor!") 445 | m.AddAlternative("text/html", "¡Hola, señor!") 446 | m.Attach(mockCopyFile("test.pdf")) 447 | m.Embed(mockCopyFile("image.jpg")) 448 | 449 | want := &message{ 450 | from: "from@example.com", 451 | to: []string{"to@example.com"}, 452 | content: "From: from@example.com\r\n" + 453 | "To: to@example.com\r\n" + 454 | "Content-Type: multipart/mixed;\r\n" + 455 | " boundary=_BOUNDARY_1_\r\n" + 456 | "\r\n" + 457 | "--_BOUNDARY_1_\r\n" + 458 | "Content-Type: multipart/related;\r\n" + 459 | " boundary=_BOUNDARY_2_\r\n" + 460 | "\r\n" + 461 | "--_BOUNDARY_2_\r\n" + 462 | "Content-Type: multipart/alternative;\r\n" + 463 | " boundary=_BOUNDARY_3_\r\n" + 464 | "\r\n" + 465 | "--_BOUNDARY_3_\r\n" + 466 | "Content-Type: text/plain; charset=UTF-8\r\n" + 467 | "Content-Transfer-Encoding: quoted-printable\r\n" + 468 | "\r\n" + 469 | "=C2=A1Hola, se=C3=B1or!\r\n" + 470 | "--_BOUNDARY_3_\r\n" + 471 | "Content-Type: text/html; charset=UTF-8\r\n" + 472 | "Content-Transfer-Encoding: quoted-printable\r\n" + 473 | "\r\n" + 474 | "=C2=A1Hola, se=C3=B1or!\r\n" + 475 | "--_BOUNDARY_3_--\r\n" + 476 | "\r\n" + 477 | "--_BOUNDARY_2_\r\n" + 478 | "Content-Type: image/jpeg; name=\"image.jpg\"\r\n" + 479 | "Content-Disposition: inline; filename=\"image.jpg\"\r\n" + 480 | "Content-ID: \r\n" + 481 | "Content-Transfer-Encoding: base64\r\n" + 482 | "\r\n" + 483 | base64.StdEncoding.EncodeToString([]byte("Content of image.jpg")) + "\r\n" + 484 | "--_BOUNDARY_2_--\r\n" + 485 | "\r\n" + 486 | "--_BOUNDARY_1_\r\n" + 487 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" + 488 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + 489 | "Content-Transfer-Encoding: base64\r\n" + 490 | "\r\n" + 491 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + 492 | "--_BOUNDARY_1_--\r\n", 493 | } 494 | 495 | testMessage(t, m, 3, want) 496 | 497 | want = &message{ 498 | from: "from@example.com", 499 | to: []string{"to@example.com"}, 500 | content: "From: from@example.com\r\n" + 501 | "To: to@example.com\r\n" + 502 | "Content-Type: text/plain; charset=UTF-8\r\n" + 503 | "Content-Transfer-Encoding: quoted-printable\r\n" + 504 | "\r\n" + 505 | "Test reset", 506 | } 507 | m.Reset() 508 | m.SetHeader("From", "from@example.com") 509 | m.SetHeader("To", "to@example.com") 510 | m.SetBody("text/plain", "Test reset") 511 | testMessage(t, m, 0, want) 512 | } 513 | 514 | func TestQpLineLength(t *testing.T) { 515 | m := NewMessage() 516 | m.SetHeader("From", "from@example.com") 517 | m.SetHeader("To", "to@example.com") 518 | m.SetBody("text/plain", 519 | strings.Repeat("0", 76)+"\r\n"+ 520 | strings.Repeat("0", 75)+"à\r\n"+ 521 | strings.Repeat("0", 74)+"à\r\n"+ 522 | strings.Repeat("0", 73)+"à\r\n"+ 523 | strings.Repeat("0", 72)+"à\r\n"+ 524 | strings.Repeat("0", 75)+"\r\n"+ 525 | strings.Repeat("0", 76)+"\n") 526 | 527 | want := &message{ 528 | from: "from@example.com", 529 | to: []string{"to@example.com"}, 530 | content: "From: from@example.com\r\n" + 531 | "To: to@example.com\r\n" + 532 | "Content-Type: text/plain; charset=UTF-8\r\n" + 533 | "Content-Transfer-Encoding: quoted-printable\r\n" + 534 | "\r\n" + 535 | strings.Repeat("0", 75) + "=\r\n0\r\n" + 536 | strings.Repeat("0", 75) + "=\r\n=C3=A0\r\n" + 537 | strings.Repeat("0", 74) + "=\r\n=C3=A0\r\n" + 538 | strings.Repeat("0", 73) + "=\r\n=C3=A0\r\n" + 539 | strings.Repeat("0", 72) + "=C3=\r\n=A0\r\n" + 540 | strings.Repeat("0", 75) + "\r\n" + 541 | strings.Repeat("0", 75) + "=\r\n0\r\n", 542 | } 543 | 544 | testMessage(t, m, 0, want) 545 | } 546 | 547 | func TestBase64LineLength(t *testing.T) { 548 | m := NewMessage(SetCharset("UTF-8"), SetEncoding(Base64)) 549 | m.SetHeader("From", "from@example.com") 550 | m.SetHeader("To", "to@example.com") 551 | m.SetBody("text/plain", strings.Repeat("0", 58)) 552 | 553 | want := &message{ 554 | from: "from@example.com", 555 | to: []string{"to@example.com"}, 556 | content: "From: from@example.com\r\n" + 557 | "To: to@example.com\r\n" + 558 | "Content-Type: text/plain; charset=UTF-8\r\n" + 559 | "Content-Transfer-Encoding: base64\r\n" + 560 | "\r\n" + 561 | strings.Repeat("MDAw", 19) + "\r\nMA==", 562 | } 563 | 564 | testMessage(t, m, 0, want) 565 | } 566 | 567 | func TestEmptyName(t *testing.T) { 568 | m := NewMessage() 569 | m.SetAddressHeader("From", "from@example.com", "") 570 | 571 | want := &message{ 572 | from: "from@example.com", 573 | content: "From: from@example.com\r\n", 574 | } 575 | 576 | testMessage(t, m, 0, want) 577 | } 578 | 579 | func TestEmptyHeader(t *testing.T) { 580 | m := NewMessage() 581 | m.SetHeaders(map[string][]string{ 582 | "From": {"from@example.com"}, 583 | "X-Empty": nil, 584 | }) 585 | 586 | want := &message{ 587 | from: "from@example.com", 588 | content: "From: from@example.com\r\n" + 589 | "X-Empty:\r\n", 590 | } 591 | 592 | testMessage(t, m, 0, want) 593 | } 594 | 595 | func testMessage(t *testing.T, m *Message, bCount int, want *message) { 596 | err := Send(stubSendMail(t, bCount, want), m) 597 | if err != nil { 598 | t.Error(err) 599 | } 600 | } 601 | 602 | func stubSendMail(t *testing.T, bCount int, want *message) SendFunc { 603 | return func(from string, to []string, m io.WriterTo) error { 604 | if from != want.from { 605 | t.Fatalf("Invalid from, got %q, want %q", from, want.from) 606 | } 607 | 608 | if len(to) != len(want.to) { 609 | t.Fatalf("Invalid recipient count, \ngot %d: %q\nwant %d: %q", 610 | len(to), to, 611 | len(want.to), want.to, 612 | ) 613 | } 614 | for i := range want.to { 615 | if to[i] != want.to[i] { 616 | t.Fatalf("Invalid recipient, got %q, want %q", 617 | to[i], want.to[i], 618 | ) 619 | } 620 | } 621 | 622 | buf := new(bytes.Buffer) 623 | _, err := m.WriteTo(buf) 624 | if err != nil { 625 | t.Error(err) 626 | } 627 | got := buf.String() 628 | wantMsg := string("Mime-Version: 1.0\r\n" + 629 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + 630 | want.content) 631 | if bCount > 0 { 632 | boundaries := getBoundaries(t, bCount, got) 633 | for i, b := range boundaries { 634 | wantMsg = strings.Replace(wantMsg, "_BOUNDARY_"+strconv.Itoa(i+1)+"_", b, -1) 635 | } 636 | } 637 | 638 | compareBodies(t, got, wantMsg) 639 | 640 | return nil 641 | } 642 | } 643 | 644 | func compareBodies(t *testing.T, got, want string) { 645 | // We cannot do a simple comparison since the ordering of headers' fields 646 | // is random. 647 | gotLines := strings.Split(got, "\r\n") 648 | wantLines := strings.Split(want, "\r\n") 649 | 650 | // We only test for too many lines, missing lines are tested after 651 | if len(gotLines) > len(wantLines) { 652 | t.Fatalf("Message has too many lines, \ngot %d:\n%s\nwant %d:\n%s", len(gotLines), got, len(wantLines), want) 653 | } 654 | 655 | isInHeader := true 656 | headerStart := 0 657 | for i, line := range wantLines { 658 | if line == gotLines[i] { 659 | if line == "" { 660 | isInHeader = false 661 | } else if !isInHeader && len(line) > 2 && line[:2] == "--" { 662 | isInHeader = true 663 | headerStart = i + 1 664 | } 665 | continue 666 | } 667 | 668 | if !isInHeader { 669 | missingLine(t, line, got, want) 670 | } 671 | 672 | isMissing := true 673 | for j := headerStart; j < len(gotLines); j++ { 674 | if gotLines[j] == "" { 675 | break 676 | } 677 | if gotLines[j] == line { 678 | isMissing = false 679 | break 680 | } 681 | } 682 | if isMissing { 683 | missingLine(t, line, got, want) 684 | } 685 | } 686 | } 687 | 688 | func missingLine(t *testing.T, line, got, want string) { 689 | t.Fatalf("Missing line %q\ngot:\n%s\nwant:\n%s", line, got, want) 690 | } 691 | 692 | func getBoundaries(t *testing.T, count int, m string) []string { 693 | if matches := boundaryRegExp.FindAllStringSubmatch(m, count); matches != nil { 694 | boundaries := make([]string, count) 695 | for i, match := range matches { 696 | boundaries[i] = match[1] 697 | } 698 | return boundaries 699 | } 700 | 701 | t.Fatal("Boundary not found in body") 702 | return []string{""} 703 | } 704 | 705 | var boundaryRegExp = regexp.MustCompile("boundary=(\\w+)") 706 | 707 | func mockCopyFile(name string) (string, FileSetting) { 708 | return name, SetCopyFunc(func(w io.Writer) error { 709 | _, err := w.Write([]byte("Content of " + filepath.Base(name))) 710 | return err 711 | }) 712 | } 713 | 714 | func mockCopyFileWithHeader(m *Message, name string, h map[string][]string) (string, FileSetting, FileSetting) { 715 | name, f := mockCopyFile(name) 716 | return name, f, SetHeader(h) 717 | } 718 | 719 | func BenchmarkFull(b *testing.B) { 720 | discardFunc := SendFunc(func(from string, to []string, m io.WriterTo) error { 721 | _, err := m.WriteTo(ioutil.Discard) 722 | return err 723 | }) 724 | 725 | m := NewMessage() 726 | b.ResetTimer() 727 | for n := 0; n < b.N; n++ { 728 | m.SetAddressHeader("From", "from@example.com", "Señor From") 729 | m.SetHeaders(map[string][]string{ 730 | "To": {"to@example.com"}, 731 | "Cc": {"cc@example.com"}, 732 | "Bcc": {"bcc1@example.com", "bcc2@example.com"}, 733 | "Subject": {"¡Hola, señor!"}, 734 | }) 735 | m.SetBody("text/plain", "¡Hola, señor!") 736 | m.AddAlternative("text/html", "

¡Hola, señor!

") 737 | m.Attach(mockCopyFile("benchmark.txt")) 738 | m.Embed(mockCopyFile("benchmark.jpg")) 739 | 740 | if err := Send(discardFunc, m); err != nil { 741 | panic(err) 742 | } 743 | m.Reset() 744 | } 745 | } 746 | --------------------------------------------------------------------------------