├── .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 | [](https://travis-ci.org/go-gomail/gomail) [](http://gocover.io/gopkg.in/gomail.v2) [](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", `
`)
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 |
--------------------------------------------------------------------------------