├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── auth.go
├── auth_test.go
├── doc.go
├── errors.go
├── example_test.go
├── message.go
├── message_test.go
├── mime.go
├── mime_go14.go
├── send.go
├── send_test.go
├── smtp.go
├── smtp_test.go
└── writeto.go
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Binaries for programs and plugins
4 | *.exe
5 | *.dll
6 | *.so
7 | *.dylib
8 |
9 | # Test binary, build with `go test -c`
10 | *.test
11 |
12 | # Output of the go coverage tool, specifically when used with LiteIDE
13 | *.out
14 |
15 |
16 | # IDE's
17 | .idea/
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 |
3 | go:
4 | - 1.2
5 | - 1.3
6 | - 1.4
7 | - 1.5
8 | - 1.6
9 | - 1.7
10 | - 1.8
11 | - 1.9
12 | - master
13 |
14 | # safelist
15 | branches:
16 | only:
17 | - master
18 | - v2
19 |
20 | notifications:
21 | email: false
22 |
23 | before_install:
24 | - mkdir -p $GOPATH/src/gopkg.in &&
25 | ln -s ../github.com/go-mail/mail $GOPATH/src/gopkg.in/mail.v2
26 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 | All notable changes to this project will be documented in this file.
3 | This project adheres to [Semantic Versioning](http://semver.org/).
4 |
5 | ## *Unreleased*
6 |
7 | ## [2.3.1] - 2018-11-12
8 |
9 | ### Fixed
10 |
11 | - #39: Reverts addition of Go modules `go.mod` manifest.
12 |
13 | ## [2.3.0] - 2018-11-10
14 |
15 | ### Added
16 |
17 | - #12: Adds `SendError` to provide additional info about the cause and index of
18 | a failed attempt to transmit a batch of messages.
19 | - go-gomail#78: Adds new `Message` methods for attaching and embedding
20 | `io.Reader`s: `AttachReader` and `EmbedReader`.
21 |
22 | ### Fixed
23 |
24 | - #26: Fixes RFC 1341 compliance by properly capitalizing the
25 | `MIME-Version` header.
26 | - #30: Fixes IO errors being silently dropped in `Message.WriteTo`.
27 |
28 | ## [2.2.0] - 2018-03-01
29 |
30 | ### Added
31 |
32 | - #20: Adds `Message.SetBoundary` to allow specifying a custom MIME boundary.
33 | - #22: Adds `Message.SetBodyWriter` to make it easy to use text/template and
34 | html/template for message bodies. Contributed by Quantcast.
35 | - #25: Adds `Dialer.StartTLSPolicy` so that `MandatoryStartTLS` can be required,
36 | or `NoStartTLS` can disable it. Contributed by Quantcast.
37 |
38 | ## [2.1.0] - 2017-12-14
39 |
40 | ### Added
41 |
42 | - go-gomail#40: Adds `Dialer.LocalName` field to allow specifying the hostname
43 | sent with SMTP's HELO command.
44 | - go-gomail#47: `Message.SetBody`, `Message.AddAlternative`, and
45 | `Message.AddAlternativeWriter` allow specifying the encoding of message parts.
46 | - `Dialer.Dial`'s returned `SendCloser` automatically redials after a timeout.
47 | - go-gomail#55, go-gomail#56: Adds `Rename` to allow specifying filename
48 | of an attachment.
49 | - go-gomail#100: Exports `NetDialTimeout` to allow setting a custom dialer.
50 | - go-gomail#70: Adds `Dialer.Timeout` field to allow specifying a timeout for
51 | dials, reads, and writes.
52 |
53 | ### Changed
54 |
55 | - go-gomail#52: `Dialer.Dial` automatically uses CRAM-MD5 when available.
56 | - `Dialer.Dial` specifies a default timeout of 10 seconds.
57 | - Gomail is forked from to
58 | .
59 |
60 | ### Deprecated
61 |
62 | - go-gomail#52: `NewPlainDialer` is deprecated in favor of `NewDialer`.
63 |
64 | ### Fixed
65 |
66 | - go-gomail#41, go-gomail#42: Fixes a panic when a `Message` contains a
67 | nil header.
68 | - go-gomail#44: Fixes `AddAlternativeWriter` replacing the message body instead
69 | of adding a body part.
70 | - go-gomail#53: Folds long header lines for RFC 2047 compliance.
71 | - go-gomail#54: Fixes `Message.FormatAddress` when name is blank.
72 |
73 | ## [2.0.0] - 2015-09-02
74 |
75 | - Mailer has been removed. It has been replaced by Dialer and Sender.
76 | - `File` type and the `CreateFile` and `OpenFile` functions have been removed.
77 | - `Message.Attach` and `Message.Embed` have a new signature.
78 | - `Message.GetBodyWriter` has been removed. Use `Message.AddAlternativeWriter`
79 | instead.
80 | - `Message.Export` has been removed. `Message.WriteTo` can be used instead.
81 | - `Message.DelHeader` has been removed.
82 | - The `Bcc` header field is no longer sent. It is far more simpler and
83 | efficient: the same message is sent to all recipients instead of sending a
84 | different email to each Bcc address.
85 | - LoginAuth has been removed. `NewPlainDialer` now implements the LOGIN
86 | authentication mechanism when needed.
87 | - Go 1.2 is now required instead of Go 1.3. No external dependency are used when
88 | using Go 1.5.
89 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Thank you for contributing to Gomail! Here are a few guidelines:
2 |
3 | ## Bugs
4 |
5 | If you think you found a bug, create an issue and supply the minimum amount
6 | of code triggering the bug so it can be reproduced.
7 |
8 |
9 | ## Fixing a bug
10 |
11 | If you want to fix a bug, you can send a pull request. It should contains a
12 | new test or update an existing one to cover that bug.
13 |
14 |
15 | ## New feature proposal
16 |
17 | If you think Gomail lacks a feature, you can open an issue or send a pull
18 | request. I want to keep Gomail code and API as simple as possible so please
19 | describe your needs so we can discuss whether this feature should be added to
20 | Gomail or not.
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Alexandre Cesaro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Gomail
2 | [](https://travis-ci.org/go-mail/mail) [](http://gocover.io/github.com/go-mail/mail) [](https://godoc.org/github.com/go-mail/mail)
3 |
4 | This is an actively maintained fork of [Gomail][1] and includes fixes and
5 | improvements for a number of outstanding issues. The current progress is
6 | as follows:
7 |
8 | - [x] Timeouts and retries can be specified outside of the 10 second default.
9 | - [x] Proxying is supported through specifying a custom [NetDialTimeout][2].
10 | - [ ] Filenames are properly encoded for non-ASCII characters.
11 | - [ ] Email addresses are properly encoded for non-ASCII characters.
12 | - [ ] Embedded files and attachments are tested for their existence.
13 | - [ ] An `io.Reader` can be supplied when embedding and attaching files.
14 |
15 | See [Transitioning Existing Codebases][3] for more information on switching.
16 |
17 | [1]: https://github.com/go-gomail/gomail
18 | [2]: https://godoc.org/gopkg.in/mail.v2#NetDialTimeout
19 | [3]: #transitioning-existing-codebases
20 |
21 | ## Introduction
22 |
23 | Gomail is a simple and efficient package to send emails. It is well tested and
24 | documented.
25 |
26 | Gomail can only send emails using an SMTP server. But the API is flexible and it
27 | is easy to implement other methods for sending emails using a local Postfix, an
28 | API, etc.
29 |
30 | It requires Go 1.2 or newer. With Go 1.5, no external dependencies are used.
31 |
32 |
33 | ## Features
34 |
35 | Gomail supports:
36 | - Attachments
37 | - Embedded images
38 | - HTML and text templates
39 | - Automatic encoding of special characters
40 | - SSL and TLS
41 | - Sending multiple emails with the same SMTP connection
42 |
43 |
44 | ## Documentation
45 |
46 | https://godoc.org/github.com/go-mail/mail
47 |
48 |
49 | ## Download
50 |
51 | If you're already using a dependency manager, like [dep][dep], use the following
52 | import path:
53 |
54 | ```
55 | github.com/go-mail/mail
56 | ```
57 |
58 | If you *aren't* using vendoring, `go get` the [Gopkg.in](http://gopkg.in)
59 | import path:
60 |
61 | ```
62 | gopkg.in/mail.v2
63 | ```
64 |
65 | [dep]: https://github.com/golang/dep#readme
66 |
67 | ## Examples
68 |
69 | See the [examples in the documentation](https://godoc.org/github.com/go-mail/mail#example-package).
70 |
71 |
72 | ## FAQ
73 |
74 | ### x509: certificate signed by unknown authority
75 |
76 | If you get this error it means the certificate used by the SMTP server is not
77 | considered valid by the client running Gomail. As a quick workaround you can
78 | bypass the verification of the server's certificate chain and host name by using
79 | `SetTLSConfig`:
80 |
81 | ```go
82 | package main
83 |
84 | import (
85 | "crypto/tls"
86 |
87 | "gopkg.in/mail.v2"
88 | )
89 |
90 | func main() {
91 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456")
92 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
93 |
94 | // Send emails using d.
95 | }
96 | ```
97 |
98 | Note, however, that this is insecure and should not be used in production.
99 |
100 | ### Transitioning Existing Codebases
101 |
102 | If you're already using the original Gomail, switching is as easy as updating
103 | the import line to:
104 |
105 | ```
106 | import gomail "gopkg.in/mail.v2"
107 | ```
108 |
109 | ## Contribute
110 |
111 | Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for
112 | more info.
113 |
114 |
115 | ## Change log
116 |
117 | See [CHANGELOG.md](CHANGELOG.md).
118 |
119 |
120 | ## License
121 |
122 | [MIT](LICENSE)
123 |
124 |
125 | ## Support & Contact
126 |
127 | You can ask questions on the [Gomail
128 | thread](https://groups.google.com/d/topic/golang-nuts/jMxZHzvvEVg/discussion)
129 | in the Go mailing-list.
130 |
--------------------------------------------------------------------------------
/auth.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "net/smtp"
8 | )
9 |
10 | // loginAuth is an smtp.Auth that implements the LOGIN authentication mechanism.
11 | type loginAuth struct {
12 | username string
13 | password string
14 | host string
15 | }
16 |
17 | func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
18 | if !server.TLS {
19 | advertised := false
20 | for _, mechanism := range server.Auth {
21 | if mechanism == "LOGIN" {
22 | advertised = true
23 | break
24 | }
25 | }
26 | if !advertised {
27 | return "", nil, errors.New("gomail: unencrypted connection")
28 | }
29 | }
30 | if server.Name != a.host {
31 | return "", nil, errors.New("gomail: wrong host name")
32 | }
33 | return "LOGIN", nil, nil
34 | }
35 |
36 | func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
37 | if !more {
38 | return nil, nil
39 | }
40 |
41 | switch {
42 | case bytes.Equal(fromServer, []byte("Username:")):
43 | return []byte(a.username), nil
44 | case bytes.Equal(fromServer, []byte("Password:")):
45 | return []byte(a.password), nil
46 | default:
47 | return nil, fmt.Errorf("gomail: unexpected server challenge: %s", fromServer)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/auth_test.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "net/smtp"
5 | "testing"
6 | )
7 |
8 | const (
9 | testUser = "user"
10 | testPwd = "pwd"
11 | testHost = "smtp.example.com"
12 | )
13 |
14 | type authTest struct {
15 | auths []string
16 | challenges []string
17 | tls bool
18 | wantData []string
19 | wantError bool
20 | }
21 |
22 | func TestNoAdvertisement(t *testing.T) {
23 | testLoginAuth(t, &authTest{
24 | auths: []string{},
25 | tls: false,
26 | wantError: true,
27 | })
28 | }
29 |
30 | func TestNoAdvertisementTLS(t *testing.T) {
31 | testLoginAuth(t, &authTest{
32 | auths: []string{},
33 | challenges: []string{"Username:", "Password:"},
34 | tls: true,
35 | wantData: []string{"", testUser, testPwd},
36 | })
37 | }
38 |
39 | func TestLogin(t *testing.T) {
40 | testLoginAuth(t, &authTest{
41 | auths: []string{"PLAIN", "LOGIN"},
42 | challenges: []string{"Username:", "Password:"},
43 | tls: false,
44 | wantData: []string{"", testUser, testPwd},
45 | })
46 | }
47 |
48 | func TestLoginTLS(t *testing.T) {
49 | testLoginAuth(t, &authTest{
50 | auths: []string{"LOGIN"},
51 | challenges: []string{"Username:", "Password:"},
52 | tls: true,
53 | wantData: []string{"", testUser, testPwd},
54 | })
55 | }
56 |
57 | func testLoginAuth(t *testing.T, test *authTest) {
58 | auth := &loginAuth{
59 | username: testUser,
60 | password: testPwd,
61 | host: testHost,
62 | }
63 | server := &smtp.ServerInfo{
64 | Name: testHost,
65 | TLS: test.tls,
66 | Auth: test.auths,
67 | }
68 | proto, toServer, err := auth.Start(server)
69 | if err != nil && !test.wantError {
70 | t.Fatalf("loginAuth.Start(): %v", err)
71 | }
72 | if err != nil && test.wantError {
73 | return
74 | }
75 | if proto != "LOGIN" {
76 | t.Errorf("invalid protocol, got %q, want LOGIN", proto)
77 | }
78 |
79 | i := 0
80 | got := string(toServer)
81 | if got != test.wantData[i] {
82 | t.Errorf("Invalid response, got %q, want %q", got, test.wantData[i])
83 | }
84 |
85 | for _, challenge := range test.challenges {
86 | i++
87 | if i >= len(test.wantData) {
88 | t.Fatalf("unexpected challenge: %q", challenge)
89 | }
90 |
91 | toServer, err = auth.Next([]byte(challenge), true)
92 | if err != nil {
93 | t.Fatalf("loginAuth.Auth(): %v", err)
94 | }
95 | got = string(toServer)
96 | if got != test.wantData[i] {
97 | t.Errorf("Invalid response, got %q, want %q", got, test.wantData[i])
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | // Package gomail provides a simple interface to compose emails and to mail them
2 | // efficiently.
3 | //
4 | // More info on Github: https://github.com/go-mail/mail
5 | //
6 | package mail
7 |
--------------------------------------------------------------------------------
/errors.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import "fmt"
4 |
5 | // A SendError represents the failure to transmit a Message, detailing the cause
6 | // of the failure and index of the Message within a batch.
7 | type SendError struct {
8 | // Index specifies the index of the Message within a batch.
9 | Index uint
10 | Cause error
11 | }
12 |
13 | func (err *SendError) Error() string {
14 | return fmt.Sprintf("gomail: could not send email %d: %v",
15 | err.Index+1, err.Cause)
16 | }
17 |
--------------------------------------------------------------------------------
/example_test.go:
--------------------------------------------------------------------------------
1 | package mail_test
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "io"
7 | "log"
8 | "time"
9 |
10 | "gopkg.in/mail.v2"
11 | )
12 |
13 | func Example() {
14 | m := mail.NewMessage()
15 | m.SetHeader("From", "alex@example.com")
16 | m.SetHeader("To", "bob@example.com", "cora@example.com")
17 | m.SetAddressHeader("Cc", "dan@example.com", "Dan")
18 | m.SetHeader("Subject", "Hello!")
19 | m.SetBody("text/html", "Hello Bob and Cora!")
20 | m.Attach("/home/Alex/lolcat.jpg")
21 |
22 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456")
23 | d.StartTLSPolicy = mail.MandatoryStartTLS
24 |
25 | // Send the email to Bob, Cora and Dan.
26 | if err := d.DialAndSend(m); err != nil {
27 | panic(err)
28 | }
29 | }
30 |
31 | // A daemon that listens to a channel and sends all incoming messages.
32 | func Example_daemon() {
33 | ch := make(chan *mail.Message)
34 |
35 | go func() {
36 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456")
37 | d.StartTLSPolicy = mail.MandatoryStartTLS
38 |
39 | var s mail.SendCloser
40 | var err error
41 | open := false
42 | for {
43 | select {
44 | case m, ok := <-ch:
45 | if !ok {
46 | return
47 | }
48 | if !open {
49 | if s, err = d.Dial(); err != nil {
50 | panic(err)
51 | }
52 | open = true
53 | }
54 | if err := mail.Send(s, m); err != nil {
55 | log.Print(err)
56 | }
57 | // Close the connection to the SMTP server if no email was sent in
58 | // the last 30 seconds.
59 | case <-time.After(30 * time.Second):
60 | if open {
61 | if err := s.Close(); err != nil {
62 | panic(err)
63 | }
64 | open = false
65 | }
66 | }
67 | }
68 | }()
69 |
70 | // Use the channel in your program to send emails.
71 |
72 | // Close the channel to stop the mail daemon.
73 | close(ch)
74 | }
75 |
76 | // Efficiently send a customized newsletter to a list of recipients.
77 | func Example_newsletter() {
78 | // The list of recipients.
79 | var list []struct {
80 | Name string
81 | Address string
82 | }
83 |
84 | d := mail.NewDialer("smtp.example.com", 587, "user", "123456")
85 | d.StartTLSPolicy = mail.MandatoryStartTLS
86 | s, err := d.Dial()
87 | if err != nil {
88 | panic(err)
89 | }
90 |
91 | m := mail.NewMessage()
92 | for _, r := range list {
93 | m.SetHeader("From", "no-reply@example.com")
94 | m.SetAddressHeader("To", r.Address, r.Name)
95 | m.SetHeader("Subject", "Newsletter #1")
96 | m.SetBody("text/html", fmt.Sprintf("Hello %s!", r.Name))
97 |
98 | if err := mail.Send(s, m); err != nil {
99 | log.Printf("Could not send email to %q: %v", r.Address, err)
100 | }
101 | m.Reset()
102 | }
103 | }
104 |
105 | // Send an email using a local SMTP server.
106 | func Example_noAuth() {
107 | m := mail.NewMessage()
108 | m.SetHeader("From", "from@example.com")
109 | m.SetHeader("To", "to@example.com")
110 | m.SetHeader("Subject", "Hello!")
111 | m.SetBody("text/plain", "Hello!")
112 |
113 | d := mail.Dialer{Host: "localhost", Port: 587}
114 | if err := d.DialAndSend(m); err != nil {
115 | panic(err)
116 | }
117 | }
118 |
119 | // Send an email using an API or postfix.
120 | func Example_noSMTP() {
121 | m := mail.NewMessage()
122 | m.SetHeader("From", "from@example.com")
123 | m.SetHeader("To", "to@example.com")
124 | m.SetHeader("Subject", "Hello!")
125 | m.SetBody("text/plain", "Hello!")
126 |
127 | s := mail.SendFunc(func(from string, to []string, msg io.WriterTo) error {
128 | // Implements you email-sending function, for example by calling
129 | // an API, or running postfix, etc.
130 | fmt.Println("From:", from)
131 | fmt.Println("To:", to)
132 | return nil
133 | })
134 |
135 | if err := mail.Send(s, m); err != nil {
136 | panic(err)
137 | }
138 | // Output:
139 | // From: from@example.com
140 | // To: [to@example.com]
141 | }
142 |
143 | var m *mail.Message
144 |
145 | func ExampleSetCopyFunc() {
146 | m.Attach("foo.txt", mail.SetCopyFunc(func(w io.Writer) error {
147 | _, err := w.Write([]byte("Content of foo.txt"))
148 | return err
149 | }))
150 | }
151 |
152 | func ExampleSetHeader() {
153 | h := map[string][]string{"Content-ID": {""}}
154 | m.Attach("foo.jpg", mail.SetHeader(h))
155 | }
156 |
157 | func ExampleRename() {
158 | m.Attach("/tmp/0000146.jpg", mail.Rename("picture.jpg"))
159 | }
160 |
161 | func ExampleMessage_AddAlternative() {
162 | m.SetBody("text/plain", "Hello!")
163 | m.AddAlternative("text/html", "Hello!
")
164 | }
165 |
166 | func ExampleMessage_AddAlternativeWriter() {
167 | t := template.Must(template.New("example").Parse("Hello {{.}}!"))
168 | m.AddAlternativeWriter("text/plain", func(w io.Writer) error {
169 | return t.Execute(w, "Bob")
170 | })
171 | }
172 |
173 | func ExampleMessage_Attach() {
174 | m.Attach("/tmp/image.jpg")
175 | }
176 |
177 | func ExampleMessage_Embed() {
178 | m.Embed("/tmp/image.jpg")
179 | m.SetBody("text/html", `
`)
180 | }
181 |
182 | func ExampleMessage_FormatAddress() {
183 | m.SetHeader("To", m.FormatAddress("bob@example.com", "Bob"), m.FormatAddress("cora@example.com", "Cora"))
184 | }
185 |
186 | func ExampleMessage_FormatDate() {
187 | m.SetHeaders(map[string][]string{
188 | "X-Date": {m.FormatDate(time.Now())},
189 | })
190 | }
191 |
192 | func ExampleMessage_SetAddressHeader() {
193 | m.SetAddressHeader("To", "bob@example.com", "Bob")
194 | }
195 |
196 | func ExampleMessage_SetBody() {
197 | m.SetBody("text/plain", "Hello!")
198 | }
199 |
200 | func ExampleMessage_SetBodyWriter() {
201 | t := template.Must(template.New("example").Parse("Hello {{.}}!"))
202 | m.SetBodyWriter("text/plain", func(w io.Writer) error {
203 | return t.Execute(w, "Bob")
204 | })
205 | }
206 |
207 | func ExampleMessage_SetDateHeader() {
208 | m.SetDateHeader("X-Date", time.Now())
209 | }
210 |
211 | func ExampleMessage_SetHeader() {
212 | m.SetHeader("Subject", "Hello!")
213 | }
214 |
215 | func ExampleMessage_SetHeaders() {
216 | m.SetHeaders(map[string][]string{
217 | "From": {m.FormatAddress("alex@example.com", "Alex")},
218 | "To": {"bob@example.com", "cora@example.com"},
219 | "Subject": {"Hello"},
220 | })
221 | }
222 |
223 | func ExampleSetCharset() {
224 | m = mail.NewMessage(mail.SetCharset("ISO-8859-1"))
225 | }
226 |
227 | func ExampleSetEncoding() {
228 | m = mail.NewMessage(mail.SetEncoding(mail.Base64))
229 | }
230 |
231 | func ExampleSetPartEncoding() {
232 | m.SetBody("text/plain", "Hello!", mail.SetPartEncoding(mail.Unencoded))
233 | }
234 |
--------------------------------------------------------------------------------
/message.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 | "path/filepath"
8 | "time"
9 | )
10 |
11 | // Message represents an email.
12 | type Message struct {
13 | header header
14 | parts []*part
15 | attachments []*file
16 | embedded []*file
17 | charset string
18 | encoding Encoding
19 | hEncoder mimeEncoder
20 | buf bytes.Buffer
21 | boundary string
22 | }
23 |
24 | type header map[string][]string
25 |
26 | type part struct {
27 | contentType string
28 | copier func(io.Writer) error
29 | encoding Encoding
30 | }
31 |
32 | // NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding
33 | // by default.
34 | func NewMessage(settings ...MessageSetting) *Message {
35 | m := &Message{
36 | header: make(header),
37 | charset: "UTF-8",
38 | encoding: QuotedPrintable,
39 | }
40 |
41 | m.applySettings(settings)
42 |
43 | if m.encoding == Base64 {
44 | m.hEncoder = bEncoding
45 | } else {
46 | m.hEncoder = qEncoding
47 | }
48 |
49 | return m
50 | }
51 |
52 | // Reset resets the message so it can be reused. The message keeps its previous
53 | // settings so it is in the same state that after a call to NewMessage.
54 | func (m *Message) Reset() {
55 | for k := range m.header {
56 | delete(m.header, k)
57 | }
58 | m.parts = nil
59 | m.attachments = nil
60 | m.embedded = nil
61 | }
62 |
63 | func (m *Message) applySettings(settings []MessageSetting) {
64 | for _, s := range settings {
65 | s(m)
66 | }
67 | }
68 |
69 | // A MessageSetting can be used as an argument in NewMessage to configure an
70 | // email.
71 | type MessageSetting func(m *Message)
72 |
73 | // SetCharset is a message setting to set the charset of the email.
74 | func SetCharset(charset string) MessageSetting {
75 | return func(m *Message) {
76 | m.charset = charset
77 | }
78 | }
79 |
80 | // SetEncoding is a message setting to set the encoding of the email.
81 | func SetEncoding(enc Encoding) MessageSetting {
82 | return func(m *Message) {
83 | m.encoding = enc
84 | }
85 | }
86 |
87 | // Encoding represents a MIME encoding scheme like quoted-printable or base64.
88 | type Encoding string
89 |
90 | const (
91 | // QuotedPrintable represents the quoted-printable encoding as defined in
92 | // RFC 2045.
93 | QuotedPrintable Encoding = "quoted-printable"
94 | // Base64 represents the base64 encoding as defined in RFC 2045.
95 | Base64 Encoding = "base64"
96 | // Unencoded can be used to avoid encoding the body of an email. The headers
97 | // will still be encoded using quoted-printable encoding.
98 | Unencoded Encoding = "8bit"
99 | )
100 |
101 | // SetBoundary sets a custom multipart boundary.
102 | func (m *Message) SetBoundary(boundary string) {
103 | m.boundary = boundary
104 | }
105 |
106 | // SetHeader sets a value to the given header field.
107 | func (m *Message) SetHeader(field string, value ...string) {
108 | m.encodeHeader(value)
109 | m.header[field] = value
110 | }
111 |
112 | func (m *Message) encodeHeader(values []string) {
113 | for i := range values {
114 | values[i] = m.encodeString(values[i])
115 | }
116 | }
117 |
118 | func (m *Message) encodeString(value string) string {
119 | return m.hEncoder.Encode(m.charset, value)
120 | }
121 |
122 | // SetHeaders sets the message headers.
123 | func (m *Message) SetHeaders(h map[string][]string) {
124 | for k, v := range h {
125 | m.SetHeader(k, v...)
126 | }
127 | }
128 |
129 | // SetAddressHeader sets an address to the given header field.
130 | func (m *Message) SetAddressHeader(field, address, name string) {
131 | m.header[field] = []string{m.FormatAddress(address, name)}
132 | }
133 |
134 | // FormatAddress formats an address and a name as a valid RFC 5322 address.
135 | func (m *Message) FormatAddress(address, name string) string {
136 | if name == "" {
137 | return address
138 | }
139 |
140 | enc := m.encodeString(name)
141 | if enc == name {
142 | m.buf.WriteByte('"')
143 | for i := 0; i < len(name); i++ {
144 | b := name[i]
145 | if b == '\\' || b == '"' {
146 | m.buf.WriteByte('\\')
147 | }
148 | m.buf.WriteByte(b)
149 | }
150 | m.buf.WriteByte('"')
151 | } else if hasSpecials(name) {
152 | m.buf.WriteString(bEncoding.Encode(m.charset, name))
153 | } else {
154 | m.buf.WriteString(enc)
155 | }
156 | m.buf.WriteString(" <")
157 | m.buf.WriteString(address)
158 | m.buf.WriteByte('>')
159 |
160 | addr := m.buf.String()
161 | m.buf.Reset()
162 | return addr
163 | }
164 |
165 | func hasSpecials(text string) bool {
166 | for i := 0; i < len(text); i++ {
167 | switch c := text[i]; c {
168 | case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"':
169 | return true
170 | }
171 | }
172 |
173 | return false
174 | }
175 |
176 | // SetDateHeader sets a date to the given header field.
177 | func (m *Message) SetDateHeader(field string, date time.Time) {
178 | m.header[field] = []string{m.FormatDate(date)}
179 | }
180 |
181 | // FormatDate formats a date as a valid RFC 5322 date.
182 | func (m *Message) FormatDate(date time.Time) string {
183 | return date.Format(time.RFC1123Z)
184 | }
185 |
186 | // GetHeader gets a header field.
187 | func (m *Message) GetHeader(field string) []string {
188 | return m.header[field]
189 | }
190 |
191 | // SetBody sets the body of the message. It replaces any content previously set
192 | // by SetBody, SetBodyWriter, AddAlternative or AddAlternativeWriter.
193 | func (m *Message) SetBody(contentType, body string, settings ...PartSetting) {
194 | m.SetBodyWriter(contentType, newCopier(body), settings...)
195 | }
196 |
197 | // SetBodyWriter sets the body of the message. It can be useful with the
198 | // text/template or html/template packages.
199 | func (m *Message) SetBodyWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) {
200 | m.parts = []*part{m.newPart(contentType, f, settings)}
201 | }
202 |
203 | // AddAlternative adds an alternative part to the message.
204 | //
205 | // It is commonly used to send HTML emails that default to the plain text
206 | // version for backward compatibility. AddAlternative appends the new part to
207 | // the end of the message. So the plain text part should be added before the
208 | // HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative
209 | func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) {
210 | m.AddAlternativeWriter(contentType, newCopier(body), settings...)
211 | }
212 |
213 | func newCopier(s string) func(io.Writer) error {
214 | return func(w io.Writer) error {
215 | _, err := io.WriteString(w, s)
216 | return err
217 | }
218 | }
219 |
220 | // AddAlternativeWriter adds an alternative part to the message. It can be
221 | // useful with the text/template or html/template packages.
222 | func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) {
223 | m.parts = append(m.parts, m.newPart(contentType, f, settings))
224 | }
225 |
226 | func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part {
227 | p := &part{
228 | contentType: contentType,
229 | copier: f,
230 | encoding: m.encoding,
231 | }
232 |
233 | for _, s := range settings {
234 | s(p)
235 | }
236 |
237 | return p
238 | }
239 |
240 | // A PartSetting can be used as an argument in Message.SetBody,
241 | // Message.SetBodyWriter, Message.AddAlternative or Message.AddAlternativeWriter
242 | // to configure the part added to a message.
243 | type PartSetting func(*part)
244 |
245 | // SetPartEncoding sets the encoding of the part added to the message. By
246 | // default, parts use the same encoding than the message.
247 | func SetPartEncoding(e Encoding) PartSetting {
248 | return PartSetting(func(p *part) {
249 | p.encoding = e
250 | })
251 | }
252 |
253 | type file struct {
254 | Name string
255 | Header map[string][]string
256 | CopyFunc func(w io.Writer) error
257 | }
258 |
259 | func (f *file) setHeader(field, value string) {
260 | f.Header[field] = []string{value}
261 | }
262 |
263 | // A FileSetting can be used as an argument in Message.Attach or Message.Embed.
264 | type FileSetting func(*file)
265 |
266 | // SetHeader is a file setting to set the MIME header of the message part that
267 | // contains the file content.
268 | //
269 | // Mandatory headers are automatically added if they are not set when sending
270 | // the email.
271 | func SetHeader(h map[string][]string) FileSetting {
272 | return func(f *file) {
273 | for k, v := range h {
274 | f.Header[k] = v
275 | }
276 | }
277 | }
278 |
279 | // Rename is a file setting to set the name of the attachment if the name is
280 | // different than the filename on disk.
281 | func Rename(name string) FileSetting {
282 | return func(f *file) {
283 | f.Name = name
284 | }
285 | }
286 |
287 | // SetCopyFunc is a file setting to replace the function that runs when the
288 | // message is sent. It should copy the content of the file to the io.Writer.
289 | //
290 | // The default copy function opens the file with the given filename, and copy
291 | // its content to the io.Writer.
292 | func SetCopyFunc(f func(io.Writer) error) FileSetting {
293 | return func(fi *file) {
294 | fi.CopyFunc = f
295 | }
296 | }
297 |
298 | // AttachReader attaches a file using an io.Reader
299 | func (m *Message) AttachReader(name string, r io.Reader, settings ...FileSetting) {
300 | m.attachments = m.appendFile(m.attachments, fileFromReader(name, r), settings)
301 | }
302 |
303 | // Attach attaches the files to the email.
304 | func (m *Message) Attach(filename string, settings ...FileSetting) {
305 | m.attachments = m.appendFile(m.attachments, fileFromFilename(filename), settings)
306 | }
307 |
308 | // EmbedReader embeds the images to the email.
309 | func (m *Message) EmbedReader(name string, r io.Reader, settings ...FileSetting) {
310 | m.embedded = m.appendFile(m.embedded, fileFromReader(name, r), settings)
311 | }
312 |
313 | // Embed embeds the images to the email.
314 | func (m *Message) Embed(filename string, settings ...FileSetting) {
315 | m.embedded = m.appendFile(m.embedded, fileFromFilename(filename), settings)
316 | }
317 |
318 | func fileFromFilename(name string) *file {
319 | return &file{
320 | Name: filepath.Base(name),
321 | Header: make(map[string][]string),
322 | CopyFunc: func(w io.Writer) error {
323 | h, err := os.Open(name)
324 | if err != nil {
325 | return err
326 | }
327 | if _, err := io.Copy(w, h); err != nil {
328 | h.Close()
329 | return err
330 | }
331 | return h.Close()
332 | },
333 | }
334 | }
335 |
336 | func fileFromReader(name string, r io.Reader) *file {
337 | return &file{
338 | Name: filepath.Base(name),
339 | Header: make(map[string][]string),
340 | CopyFunc: func(w io.Writer) error {
341 | if _, err := io.Copy(w, r); err != nil {
342 | return err
343 | }
344 | return nil
345 | },
346 | }
347 | }
348 |
349 | func (m *Message) appendFile(list []*file, f *file, settings []FileSetting) []*file {
350 | for _, s := range settings {
351 | s(f)
352 | }
353 |
354 | if list == nil {
355 | return []*file{f}
356 | }
357 |
358 | return append(list, f)
359 | }
360 |
--------------------------------------------------------------------------------
/message_test.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "bytes"
5 | "encoding/base64"
6 | "io"
7 | "io/ioutil"
8 | "path/filepath"
9 | "regexp"
10 | "strconv"
11 | "strings"
12 | "testing"
13 | "time"
14 | )
15 |
16 | func init() {
17 | now = func() time.Time {
18 | return time.Date(2014, 06, 25, 17, 46, 0, 0, time.UTC)
19 | }
20 | }
21 |
22 | type message struct {
23 | from string
24 | to []string
25 | content string
26 | }
27 |
28 | func TestMessage(t *testing.T) {
29 | m := NewMessage()
30 | m.SetAddressHeader("From", "from@example.com", "Señor From")
31 | m.SetHeader("To", m.FormatAddress("to@example.com", "Señor To"), "tobis@example.com")
32 | m.SetAddressHeader("Cc", "cc@example.com", "A, B")
33 | m.SetAddressHeader("X-To", "ccbis@example.com", "à, b")
34 | m.SetDateHeader("X-Date", now())
35 | m.SetHeader("X-Date-2", m.FormatDate(now()))
36 | m.SetHeader("Subject", "¡Hola, señor!")
37 | m.SetHeaders(map[string][]string{
38 | "X-Headers": {"Test", "Café"},
39 | })
40 | m.SetBody("text/plain", "¡Hola, señor!")
41 |
42 | want := &message{
43 | from: "from@example.com",
44 | to: []string{
45 | "to@example.com",
46 | "tobis@example.com",
47 | "cc@example.com",
48 | },
49 | content: "From: =?UTF-8?q?Se=C3=B1or_From?= \r\n" +
50 | "To: =?UTF-8?q?Se=C3=B1or_To?= , tobis@example.com\r\n" +
51 | "Cc: \"A, B\" \r\n" +
52 | "X-To: =?UTF-8?b?w6AsIGI=?= \r\n" +
53 | "X-Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" +
54 | "X-Date-2: Wed, 25 Jun 2014 17:46:00 +0000\r\n" +
55 | "X-Headers: Test, =?UTF-8?q?Caf=C3=A9?=\r\n" +
56 | "Subject: =?UTF-8?q?=C2=A1Hola,_se=C3=B1or!?=\r\n" +
57 | "Content-Type: text/plain; charset=UTF-8\r\n" +
58 | "Content-Transfer-Encoding: quoted-printable\r\n" +
59 | "\r\n" +
60 | "=C2=A1Hola, se=C3=B1or!",
61 | }
62 |
63 | testMessage(t, m, 0, want)
64 | }
65 |
66 | func TestCustomMessage(t *testing.T) {
67 | m := NewMessage(SetCharset("ISO-8859-1"), SetEncoding(Base64))
68 | m.SetHeaders(map[string][]string{
69 | "From": {"from@example.com"},
70 | "To": {"to@example.com"},
71 | "Subject": {"Café"},
72 | })
73 | m.SetBody("text/html", "¡Hola, señor!")
74 |
75 | want := &message{
76 | from: "from@example.com",
77 | to: []string{"to@example.com"},
78 | content: "From: from@example.com\r\n" +
79 | "To: to@example.com\r\n" +
80 | "Subject: =?ISO-8859-1?b?Q2Fmw6k=?=\r\n" +
81 | "Content-Type: text/html; charset=ISO-8859-1\r\n" +
82 | "Content-Transfer-Encoding: base64\r\n" +
83 | "\r\n" +
84 | "wqFIb2xhLCBzZcOxb3Ih",
85 | }
86 |
87 | testMessage(t, m, 0, want)
88 | }
89 |
90 | func TestUnencodedMessage(t *testing.T) {
91 | m := NewMessage(SetEncoding(Unencoded))
92 | m.SetHeaders(map[string][]string{
93 | "From": {"from@example.com"},
94 | "To": {"to@example.com"},
95 | "Subject": {"Café"},
96 | })
97 | m.SetBody("text/html", "¡Hola, señor!")
98 |
99 | want := &message{
100 | from: "from@example.com",
101 | to: []string{"to@example.com"},
102 | content: "From: from@example.com\r\n" +
103 | "To: to@example.com\r\n" +
104 | "Subject: =?UTF-8?q?Caf=C3=A9?=\r\n" +
105 | "Content-Type: text/html; charset=UTF-8\r\n" +
106 | "Content-Transfer-Encoding: 8bit\r\n" +
107 | "\r\n" +
108 | "¡Hola, señor!",
109 | }
110 |
111 | testMessage(t, m, 0, want)
112 | }
113 |
114 | func TestRecipients(t *testing.T) {
115 | m := NewMessage()
116 | m.SetHeaders(map[string][]string{
117 | "From": {"from@example.com"},
118 | "To": {"to@example.com"},
119 | "Cc": {"cc@example.com"},
120 | "Bcc": {"bcc1@example.com", "bcc2@example.com"},
121 | "Subject": {"Hello!"},
122 | })
123 | m.SetBody("text/plain", "Test message")
124 |
125 | want := &message{
126 | from: "from@example.com",
127 | to: []string{"to@example.com", "cc@example.com", "bcc1@example.com", "bcc2@example.com"},
128 | content: "From: from@example.com\r\n" +
129 | "To: to@example.com\r\n" +
130 | "Cc: cc@example.com\r\n" +
131 | "Subject: Hello!\r\n" +
132 | "Content-Type: text/plain; charset=UTF-8\r\n" +
133 | "Content-Transfer-Encoding: quoted-printable\r\n" +
134 | "\r\n" +
135 | "Test message",
136 | }
137 |
138 | testMessage(t, m, 0, want)
139 | }
140 |
141 | func TestAlternative(t *testing.T) {
142 | m := NewMessage()
143 | m.SetHeader("From", "from@example.com")
144 | m.SetHeader("To", "to@example.com")
145 | m.SetBody("text/plain", "¡Hola, señor!")
146 | m.AddAlternative("text/html", "¡Hola, señor!")
147 |
148 | want := &message{
149 | from: "from@example.com",
150 | to: []string{"to@example.com"},
151 | content: "From: from@example.com\r\n" +
152 | "To: to@example.com\r\n" +
153 | "Content-Type: multipart/alternative;\r\n" +
154 | " boundary=_BOUNDARY_1_\r\n" +
155 | "\r\n" +
156 | "--_BOUNDARY_1_\r\n" +
157 | "Content-Type: text/plain; charset=UTF-8\r\n" +
158 | "Content-Transfer-Encoding: quoted-printable\r\n" +
159 | "\r\n" +
160 | "=C2=A1Hola, se=C3=B1or!\r\n" +
161 | "--_BOUNDARY_1_\r\n" +
162 | "Content-Type: text/html; charset=UTF-8\r\n" +
163 | "Content-Transfer-Encoding: quoted-printable\r\n" +
164 | "\r\n" +
165 | "=C2=A1Hola, se=C3=B1or!\r\n" +
166 | "--_BOUNDARY_1_--\r\n",
167 | }
168 |
169 | testMessage(t, m, 1, want)
170 | }
171 |
172 | func TestPartSetting(t *testing.T) {
173 | m := NewMessage()
174 | m.SetHeader("From", "from@example.com")
175 | m.SetHeader("To", "to@example.com")
176 | m.SetBody("text/plain; format=flowed", "¡Hola, señor!", SetPartEncoding(Unencoded))
177 | m.AddAlternative("text/html", "¡Hola, señor!")
178 |
179 | want := &message{
180 | from: "from@example.com",
181 | to: []string{"to@example.com"},
182 | content: "From: from@example.com\r\n" +
183 | "To: to@example.com\r\n" +
184 | "Content-Type: multipart/alternative;\r\n" +
185 | " boundary=_BOUNDARY_1_\r\n" +
186 | "\r\n" +
187 | "--_BOUNDARY_1_\r\n" +
188 | "Content-Type: text/plain; format=flowed; charset=UTF-8\r\n" +
189 | "Content-Transfer-Encoding: 8bit\r\n" +
190 | "\r\n" +
191 | "¡Hola, señor!\r\n" +
192 | "--_BOUNDARY_1_\r\n" +
193 | "Content-Type: text/html; charset=UTF-8\r\n" +
194 | "Content-Transfer-Encoding: quoted-printable\r\n" +
195 | "\r\n" +
196 | "=C2=A1Hola, se=C3=B1or!\r\n" +
197 | "--_BOUNDARY_1_--\r\n",
198 | }
199 |
200 | testMessage(t, m, 1, want)
201 | }
202 |
203 | func TestPartSettingWithCustomBoundary(t *testing.T) {
204 | m := NewMessage()
205 | m.SetBoundary("lalalaDaiMne3Ryblya")
206 | m.SetHeader("From", "from@example.com")
207 | m.SetHeader("To", "to@example.com")
208 | m.SetBody("text/plain; format=flowed", "¡Hola, señor!", SetPartEncoding(Unencoded))
209 | m.AddAlternative("text/html", "¡Hola, señor!")
210 |
211 | want := &message{
212 | from: "from@example.com",
213 | to: []string{"to@example.com"},
214 | content: "From: from@example.com\r\n" +
215 | "To: to@example.com\r\n" +
216 | "Content-Type: multipart/alternative;\r\n" +
217 | " boundary=lalalaDaiMne3Ryblya\r\n" +
218 | "\r\n" +
219 | "--lalalaDaiMne3Ryblya\r\n" +
220 | "Content-Type: text/plain; format=flowed; charset=UTF-8\r\n" +
221 | "Content-Transfer-Encoding: 8bit\r\n" +
222 | "\r\n" +
223 | "¡Hola, señor!\r\n" +
224 | "--lalalaDaiMne3Ryblya\r\n" +
225 | "Content-Type: text/html; charset=UTF-8\r\n" +
226 | "Content-Transfer-Encoding: quoted-printable\r\n" +
227 | "\r\n" +
228 | "=C2=A1Hola, se=C3=B1or!\r\n" +
229 | "--lalalaDaiMne3Ryblya--\r\n",
230 | }
231 |
232 | testMessage(t, m, 1, want)
233 | }
234 |
235 | func TestBodyWriter(t *testing.T) {
236 | m := NewMessage()
237 | m.SetHeader("From", "from@example.com")
238 | m.SetHeader("To", "to@example.com")
239 | m.SetBodyWriter("text/plain", func(w io.Writer) error {
240 | _, err := w.Write([]byte("Test message"))
241 | return err
242 | })
243 | m.AddAlternativeWriter("text/html", func(w io.Writer) error {
244 | _, err := w.Write([]byte("Test HTML"))
245 | return err
246 | })
247 |
248 | want := &message{
249 | from: "from@example.com",
250 | to: []string{"to@example.com"},
251 | content: "From: from@example.com\r\n" +
252 | "To: to@example.com\r\n" +
253 | "Content-Type: multipart/alternative;\r\n" +
254 | " boundary=_BOUNDARY_1_\r\n" +
255 | "\r\n" +
256 | "--_BOUNDARY_1_\r\n" +
257 | "Content-Type: text/plain; charset=UTF-8\r\n" +
258 | "Content-Transfer-Encoding: quoted-printable\r\n" +
259 | "\r\n" +
260 | "Test message\r\n" +
261 | "--_BOUNDARY_1_\r\n" +
262 | "Content-Type: text/html; charset=UTF-8\r\n" +
263 | "Content-Transfer-Encoding: quoted-printable\r\n" +
264 | "\r\n" +
265 | "Test HTML\r\n" +
266 | "--_BOUNDARY_1_--\r\n",
267 | }
268 |
269 | testMessage(t, m, 1, want)
270 | }
271 |
272 | func TestAttachmentReader(t *testing.T) {
273 | m := NewMessage()
274 | m.SetHeader("From", "from@example.com")
275 | m.SetHeader("To", "to@example.com")
276 |
277 | var b bytes.Buffer
278 | b.Write([]byte("Test file"))
279 | m.AttachReader("file.txt", &b)
280 |
281 | want := &message{
282 | from: "from@example.com",
283 | to: []string{"to@example.com"},
284 | content: "From: from@example.com\r\n" +
285 | "To: to@example.com\r\n" +
286 | "Content-Type: text/plain; charset=utf-8; name=\"file.txt\"\r\n" +
287 | "Content-Disposition: attachment; filename=\"file.txt\"\r\n" +
288 | "Content-Transfer-Encoding: base64\r\n" +
289 | "\r\n" +
290 | base64.StdEncoding.EncodeToString([]byte("Test file")),
291 | }
292 |
293 | testMessage(t, m, 0, want)
294 | }
295 |
296 | func TestAttachmentOnly(t *testing.T) {
297 | m := NewMessage()
298 | m.SetHeader("From", "from@example.com")
299 | m.SetHeader("To", "to@example.com")
300 | m.Attach(mockCopyFile("/tmp/test.pdf"))
301 |
302 | want := &message{
303 | from: "from@example.com",
304 | to: []string{"to@example.com"},
305 | content: "From: from@example.com\r\n" +
306 | "To: to@example.com\r\n" +
307 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" +
308 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
309 | "Content-Transfer-Encoding: base64\r\n" +
310 | "\r\n" +
311 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")),
312 | }
313 |
314 | testMessage(t, m, 0, want)
315 | }
316 |
317 | func TestAttachment(t *testing.T) {
318 | m := NewMessage()
319 | m.SetHeader("From", "from@example.com")
320 | m.SetHeader("To", "to@example.com")
321 | m.SetBody("text/plain", "Test")
322 | m.Attach(mockCopyFile("/tmp/test.pdf"))
323 |
324 | want := &message{
325 | from: "from@example.com",
326 | to: []string{"to@example.com"},
327 | content: "From: from@example.com\r\n" +
328 | "To: to@example.com\r\n" +
329 | "Content-Type: multipart/mixed;\r\n" +
330 | " boundary=_BOUNDARY_1_\r\n" +
331 | "\r\n" +
332 | "--_BOUNDARY_1_\r\n" +
333 | "Content-Type: text/plain; charset=UTF-8\r\n" +
334 | "Content-Transfer-Encoding: quoted-printable\r\n" +
335 | "\r\n" +
336 | "Test\r\n" +
337 | "--_BOUNDARY_1_\r\n" +
338 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" +
339 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
340 | "Content-Transfer-Encoding: base64\r\n" +
341 | "\r\n" +
342 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" +
343 | "--_BOUNDARY_1_--\r\n",
344 | }
345 |
346 | testMessage(t, m, 1, want)
347 | }
348 |
349 | func TestRename(t *testing.T) {
350 | m := NewMessage()
351 | m.SetHeader("From", "from@example.com")
352 | m.SetHeader("To", "to@example.com")
353 | m.SetBody("text/plain", "Test")
354 | name, copy := mockCopyFile("/tmp/test.pdf")
355 | rename := Rename("another.pdf")
356 | m.Attach(name, copy, rename)
357 |
358 | want := &message{
359 | from: "from@example.com",
360 | to: []string{"to@example.com"},
361 | content: "From: from@example.com\r\n" +
362 | "To: to@example.com\r\n" +
363 | "Content-Type: multipart/mixed;\r\n" +
364 | " boundary=_BOUNDARY_1_\r\n" +
365 | "\r\n" +
366 | "--_BOUNDARY_1_\r\n" +
367 | "Content-Type: text/plain; charset=UTF-8\r\n" +
368 | "Content-Transfer-Encoding: quoted-printable\r\n" +
369 | "\r\n" +
370 | "Test\r\n" +
371 | "--_BOUNDARY_1_\r\n" +
372 | "Content-Type: application/pdf; name=\"another.pdf\"\r\n" +
373 | "Content-Disposition: attachment; filename=\"another.pdf\"\r\n" +
374 | "Content-Transfer-Encoding: base64\r\n" +
375 | "\r\n" +
376 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" +
377 | "--_BOUNDARY_1_--\r\n",
378 | }
379 |
380 | testMessage(t, m, 1, want)
381 | }
382 |
383 | func TestAttachmentsOnly(t *testing.T) {
384 | m := NewMessage()
385 | m.SetHeader("From", "from@example.com")
386 | m.SetHeader("To", "to@example.com")
387 | m.Attach(mockCopyFile("/tmp/test.pdf"))
388 | m.Attach(mockCopyFile("/tmp/test.zip"))
389 |
390 | want := &message{
391 | from: "from@example.com",
392 | to: []string{"to@example.com"},
393 | content: "From: from@example.com\r\n" +
394 | "To: to@example.com\r\n" +
395 | "Content-Type: multipart/mixed;\r\n" +
396 | " boundary=_BOUNDARY_1_\r\n" +
397 | "\r\n" +
398 | "--_BOUNDARY_1_\r\n" +
399 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" +
400 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
401 | "Content-Transfer-Encoding: base64\r\n" +
402 | "\r\n" +
403 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" +
404 | "--_BOUNDARY_1_\r\n" +
405 | "Content-Type: application/zip; name=\"test.zip\"\r\n" +
406 | "Content-Disposition: attachment; filename=\"test.zip\"\r\n" +
407 | "Content-Transfer-Encoding: base64\r\n" +
408 | "\r\n" +
409 | base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" +
410 | "--_BOUNDARY_1_--\r\n",
411 | }
412 |
413 | testMessage(t, m, 1, want)
414 | }
415 |
416 | func TestAttachments(t *testing.T) {
417 | m := NewMessage()
418 | m.SetHeader("From", "from@example.com")
419 | m.SetHeader("To", "to@example.com")
420 | m.SetBody("text/plain", "Test")
421 | m.Attach(mockCopyFile("/tmp/test.pdf"))
422 | m.Attach(mockCopyFile("/tmp/test.zip"))
423 |
424 | want := &message{
425 | from: "from@example.com",
426 | to: []string{"to@example.com"},
427 | content: "From: from@example.com\r\n" +
428 | "To: to@example.com\r\n" +
429 | "Content-Type: multipart/mixed;\r\n" +
430 | " boundary=_BOUNDARY_1_\r\n" +
431 | "\r\n" +
432 | "--_BOUNDARY_1_\r\n" +
433 | "Content-Type: text/plain; charset=UTF-8\r\n" +
434 | "Content-Transfer-Encoding: quoted-printable\r\n" +
435 | "\r\n" +
436 | "Test\r\n" +
437 | "--_BOUNDARY_1_\r\n" +
438 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" +
439 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
440 | "Content-Transfer-Encoding: base64\r\n" +
441 | "\r\n" +
442 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" +
443 | "--_BOUNDARY_1_\r\n" +
444 | "Content-Type: application/zip; name=\"test.zip\"\r\n" +
445 | "Content-Disposition: attachment; filename=\"test.zip\"\r\n" +
446 | "Content-Transfer-Encoding: base64\r\n" +
447 | "\r\n" +
448 | base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" +
449 | "--_BOUNDARY_1_--\r\n",
450 | }
451 |
452 | testMessage(t, m, 1, want)
453 | }
454 |
455 | func TestEmbeddedReader(t *testing.T) {
456 | m := NewMessage()
457 | m.SetHeader("From", "from@example.com")
458 | m.SetHeader("To", "to@example.com")
459 |
460 | var b bytes.Buffer
461 | b.Write([]byte("Test file"))
462 | m.EmbedReader("file.txt", &b)
463 |
464 | want := &message{
465 | from: "from@example.com",
466 | to: []string{"to@example.com"},
467 | content: "From: from@example.com\r\n" +
468 | "To: to@example.com\r\n" +
469 | "Content-Type: text/plain; charset=utf-8; name=\"file.txt\"\r\n" +
470 | "Content-Transfer-Encoding: base64\r\n" +
471 | "Content-Disposition: inline; filename=\"file.txt\"\r\n" +
472 | "Content-ID: \r\n" +
473 | "\r\n" +
474 | base64.StdEncoding.EncodeToString([]byte("Test file")),
475 | }
476 |
477 | testMessage(t, m, 0, want)
478 | }
479 |
480 | func TestEmbedded(t *testing.T) {
481 | m := NewMessage()
482 | m.SetHeader("From", "from@example.com")
483 | m.SetHeader("To", "to@example.com")
484 | m.Embed(mockCopyFileWithHeader(m, "image1.jpg", map[string][]string{"Content-ID": {""}}))
485 | m.Embed(mockCopyFile("image2.jpg"))
486 | m.SetBody("text/plain", "Test")
487 |
488 | want := &message{
489 | from: "from@example.com",
490 | to: []string{"to@example.com"},
491 | content: "From: from@example.com\r\n" +
492 | "To: to@example.com\r\n" +
493 | "Content-Type: multipart/related;\r\n" +
494 | " boundary=_BOUNDARY_1_\r\n" +
495 | "\r\n" +
496 | "--_BOUNDARY_1_\r\n" +
497 | "Content-Type: text/plain; charset=UTF-8\r\n" +
498 | "Content-Transfer-Encoding: quoted-printable\r\n" +
499 | "\r\n" +
500 | "Test\r\n" +
501 | "--_BOUNDARY_1_\r\n" +
502 | "Content-Type: image/jpeg; name=\"image1.jpg\"\r\n" +
503 | "Content-Disposition: inline; filename=\"image1.jpg\"\r\n" +
504 | "Content-ID: \r\n" +
505 | "Content-Transfer-Encoding: base64\r\n" +
506 | "\r\n" +
507 | base64.StdEncoding.EncodeToString([]byte("Content of image1.jpg")) + "\r\n" +
508 | "--_BOUNDARY_1_\r\n" +
509 | "Content-Type: image/jpeg; name=\"image2.jpg\"\r\n" +
510 | "Content-Disposition: inline; filename=\"image2.jpg\"\r\n" +
511 | "Content-ID: \r\n" +
512 | "Content-Transfer-Encoding: base64\r\n" +
513 | "\r\n" +
514 | base64.StdEncoding.EncodeToString([]byte("Content of image2.jpg")) + "\r\n" +
515 | "--_BOUNDARY_1_--\r\n",
516 | }
517 |
518 | testMessage(t, m, 1, want)
519 | }
520 |
521 | func TestFullMessage(t *testing.T) {
522 | m := NewMessage()
523 | m.SetHeader("From", "from@example.com")
524 | m.SetHeader("To", "to@example.com")
525 | m.SetBody("text/plain", "¡Hola, señor!")
526 | m.AddAlternative("text/html", "¡Hola, señor!")
527 | m.Attach(mockCopyFile("test.pdf"))
528 | m.Embed(mockCopyFile("image.jpg"))
529 |
530 | want := &message{
531 | from: "from@example.com",
532 | to: []string{"to@example.com"},
533 | content: "From: from@example.com\r\n" +
534 | "To: to@example.com\r\n" +
535 | "Content-Type: multipart/mixed;\r\n" +
536 | " boundary=_BOUNDARY_1_\r\n" +
537 | "\r\n" +
538 | "--_BOUNDARY_1_\r\n" +
539 | "Content-Type: multipart/related;\r\n" +
540 | " boundary=_BOUNDARY_2_\r\n" +
541 | "\r\n" +
542 | "--_BOUNDARY_2_\r\n" +
543 | "Content-Type: multipart/alternative;\r\n" +
544 | " boundary=_BOUNDARY_3_\r\n" +
545 | "\r\n" +
546 | "--_BOUNDARY_3_\r\n" +
547 | "Content-Type: text/plain; charset=UTF-8\r\n" +
548 | "Content-Transfer-Encoding: quoted-printable\r\n" +
549 | "\r\n" +
550 | "=C2=A1Hola, se=C3=B1or!\r\n" +
551 | "--_BOUNDARY_3_\r\n" +
552 | "Content-Type: text/html; charset=UTF-8\r\n" +
553 | "Content-Transfer-Encoding: quoted-printable\r\n" +
554 | "\r\n" +
555 | "=C2=A1Hola, se=C3=B1or!\r\n" +
556 | "--_BOUNDARY_3_--\r\n" +
557 | "\r\n" +
558 | "--_BOUNDARY_2_\r\n" +
559 | "Content-Type: image/jpeg; name=\"image.jpg\"\r\n" +
560 | "Content-Disposition: inline; filename=\"image.jpg\"\r\n" +
561 | "Content-ID: \r\n" +
562 | "Content-Transfer-Encoding: base64\r\n" +
563 | "\r\n" +
564 | base64.StdEncoding.EncodeToString([]byte("Content of image.jpg")) + "\r\n" +
565 | "--_BOUNDARY_2_--\r\n" +
566 | "\r\n" +
567 | "--_BOUNDARY_1_\r\n" +
568 | "Content-Type: application/pdf; name=\"test.pdf\"\r\n" +
569 | "Content-Disposition: attachment; filename=\"test.pdf\"\r\n" +
570 | "Content-Transfer-Encoding: base64\r\n" +
571 | "\r\n" +
572 | base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" +
573 | "--_BOUNDARY_1_--\r\n",
574 | }
575 |
576 | testMessage(t, m, 3, want)
577 |
578 | want = &message{
579 | from: "from@example.com",
580 | to: []string{"to@example.com"},
581 | content: "From: from@example.com\r\n" +
582 | "To: to@example.com\r\n" +
583 | "Content-Type: text/plain; charset=UTF-8\r\n" +
584 | "Content-Transfer-Encoding: quoted-printable\r\n" +
585 | "\r\n" +
586 | "Test reset",
587 | }
588 | m.Reset()
589 | m.SetHeader("From", "from@example.com")
590 | m.SetHeader("To", "to@example.com")
591 | m.SetBody("text/plain", "Test reset")
592 | testMessage(t, m, 0, want)
593 | }
594 |
595 | func TestQpLineLength(t *testing.T) {
596 | m := NewMessage()
597 | m.SetHeader("From", "from@example.com")
598 | m.SetHeader("To", "to@example.com")
599 | m.SetBody("text/plain",
600 | strings.Repeat("0", 76)+"\r\n"+
601 | strings.Repeat("0", 75)+"à\r\n"+
602 | strings.Repeat("0", 74)+"à\r\n"+
603 | strings.Repeat("0", 73)+"à\r\n"+
604 | strings.Repeat("0", 72)+"à\r\n"+
605 | strings.Repeat("0", 75)+"\r\n"+
606 | strings.Repeat("0", 76)+"\n")
607 |
608 | want := &message{
609 | from: "from@example.com",
610 | to: []string{"to@example.com"},
611 | content: "From: from@example.com\r\n" +
612 | "To: to@example.com\r\n" +
613 | "Content-Type: text/plain; charset=UTF-8\r\n" +
614 | "Content-Transfer-Encoding: quoted-printable\r\n" +
615 | "\r\n" +
616 | strings.Repeat("0", 75) + "=\r\n0\r\n" +
617 | strings.Repeat("0", 75) + "=\r\n=C3=A0\r\n" +
618 | strings.Repeat("0", 74) + "=\r\n=C3=A0\r\n" +
619 | strings.Repeat("0", 73) + "=\r\n=C3=A0\r\n" +
620 | strings.Repeat("0", 72) + "=C3=\r\n=A0\r\n" +
621 | strings.Repeat("0", 75) + "\r\n" +
622 | strings.Repeat("0", 75) + "=\r\n0\r\n",
623 | }
624 |
625 | testMessage(t, m, 0, want)
626 | }
627 |
628 | func TestBase64LineLength(t *testing.T) {
629 | m := NewMessage(SetCharset("UTF-8"), SetEncoding(Base64))
630 | m.SetHeader("From", "from@example.com")
631 | m.SetHeader("To", "to@example.com")
632 | m.SetBody("text/plain", strings.Repeat("0", 58))
633 |
634 | want := &message{
635 | from: "from@example.com",
636 | to: []string{"to@example.com"},
637 | content: "From: from@example.com\r\n" +
638 | "To: to@example.com\r\n" +
639 | "Content-Type: text/plain; charset=UTF-8\r\n" +
640 | "Content-Transfer-Encoding: base64\r\n" +
641 | "\r\n" +
642 | strings.Repeat("MDAw", 19) + "\r\nMA==",
643 | }
644 |
645 | testMessage(t, m, 0, want)
646 | }
647 |
648 | func TestEmptyName(t *testing.T) {
649 | m := NewMessage()
650 | m.SetAddressHeader("From", "from@example.com", "")
651 |
652 | want := &message{
653 | from: "from@example.com",
654 | content: "From: from@example.com\r\n",
655 | }
656 |
657 | testMessage(t, m, 0, want)
658 | }
659 |
660 | func TestEmptyHeader(t *testing.T) {
661 | m := NewMessage()
662 | m.SetHeaders(map[string][]string{
663 | "From": {"from@example.com"},
664 | "X-Empty": nil,
665 | })
666 |
667 | want := &message{
668 | from: "from@example.com",
669 | content: "From: from@example.com\r\n" +
670 | "X-Empty:\r\n",
671 | }
672 |
673 | testMessage(t, m, 0, want)
674 | }
675 |
676 | func testMessage(t *testing.T, m *Message, bCount int, want *message) {
677 | err := Send(stubSendMail(t, bCount, want), m)
678 | if err != nil {
679 | t.Error(err)
680 | }
681 | }
682 |
683 | func stubSendMail(t *testing.T, bCount int, want *message) SendFunc {
684 | return func(from string, to []string, m io.WriterTo) error {
685 | if from != want.from {
686 | t.Fatalf("Invalid from, got %q, want %q", from, want.from)
687 | }
688 |
689 | if len(to) != len(want.to) {
690 | t.Fatalf("Invalid recipient count, \ngot %d: %q\nwant %d: %q",
691 | len(to), to,
692 | len(want.to), want.to,
693 | )
694 | }
695 | for i := range want.to {
696 | if to[i] != want.to[i] {
697 | t.Fatalf("Invalid recipient, got %q, want %q",
698 | to[i], want.to[i],
699 | )
700 | }
701 | }
702 |
703 | buf := new(bytes.Buffer)
704 | _, err := m.WriteTo(buf)
705 | if err != nil {
706 | t.Error(err)
707 | }
708 | got := buf.String()
709 | wantMsg := string("MIME-Version: 1.0\r\n" +
710 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" +
711 | want.content)
712 | if bCount > 0 {
713 | boundaries := getBoundaries(t, bCount, got)
714 | for i, b := range boundaries {
715 | wantMsg = strings.Replace(wantMsg, "_BOUNDARY_"+strconv.Itoa(i+1)+"_", b, -1)
716 | }
717 | }
718 |
719 | compareBodies(t, got, wantMsg)
720 |
721 | return nil
722 | }
723 | }
724 |
725 | func compareBodies(t *testing.T, got, want string) {
726 | // We cannot do a simple comparison since the ordering of headers' fields
727 | // is random.
728 | gotLines := strings.Split(got, "\r\n")
729 | wantLines := strings.Split(want, "\r\n")
730 |
731 | // We only test for too many lines, missing lines are tested after
732 | if len(gotLines) > len(wantLines) {
733 | t.Fatalf("Message has too many lines, \ngot %d:\n%s\nwant %d:\n%s", len(gotLines), got, len(wantLines), want)
734 | }
735 |
736 | isInHeader := true
737 | headerStart := 0
738 | for i, line := range wantLines {
739 | if line == gotLines[i] {
740 | if line == "" {
741 | isInHeader = false
742 | } else if !isInHeader && len(line) > 2 && line[:2] == "--" {
743 | isInHeader = true
744 | headerStart = i + 1
745 | }
746 | continue
747 | }
748 |
749 | if !isInHeader {
750 | missingLine(t, line, got, want)
751 | }
752 |
753 | isMissing := true
754 | for j := headerStart; j < len(gotLines); j++ {
755 | if gotLines[j] == "" {
756 | break
757 | }
758 | if gotLines[j] == line {
759 | isMissing = false
760 | break
761 | }
762 | }
763 | if isMissing {
764 | missingLine(t, line, got, want)
765 | }
766 | }
767 | }
768 |
769 | func missingLine(t *testing.T, line, got, want string) {
770 | t.Fatalf("Missing line %q\ngot:\n%s\nwant:\n%s", line, got, want)
771 | }
772 |
773 | func getBoundaries(t *testing.T, count int, m string) []string {
774 | if matches := boundaryRegExp.FindAllStringSubmatch(m, count); matches != nil {
775 | boundaries := make([]string, count)
776 | for i, match := range matches {
777 | boundaries[i] = match[1]
778 | }
779 | return boundaries
780 | }
781 |
782 | t.Fatal("Boundary not found in body")
783 | return []string{""}
784 | }
785 |
786 | var boundaryRegExp = regexp.MustCompile("boundary=(\\w+)")
787 |
788 | func mockCopyFile(name string) (string, FileSetting) {
789 | return name, SetCopyFunc(func(w io.Writer) error {
790 | _, err := w.Write([]byte("Content of " + filepath.Base(name)))
791 | return err
792 | })
793 | }
794 |
795 | func mockCopyFileWithHeader(m *Message, name string, h map[string][]string) (string, FileSetting, FileSetting) {
796 | name, f := mockCopyFile(name)
797 | return name, f, SetHeader(h)
798 | }
799 |
800 | func BenchmarkFull(b *testing.B) {
801 | discardFunc := SendFunc(func(from string, to []string, m io.WriterTo) error {
802 | _, err := m.WriteTo(ioutil.Discard)
803 | return err
804 | })
805 |
806 | m := NewMessage()
807 | b.ResetTimer()
808 | for n := 0; n < b.N; n++ {
809 | m.SetAddressHeader("From", "from@example.com", "Señor From")
810 | m.SetHeaders(map[string][]string{
811 | "To": {"to@example.com"},
812 | "Cc": {"cc@example.com"},
813 | "Bcc": {"bcc1@example.com", "bcc2@example.com"},
814 | "Subject": {"¡Hola, señor!"},
815 | })
816 | m.SetBody("text/plain", "¡Hola, señor!")
817 | m.AddAlternative("text/html", "¡Hola, señor!
")
818 | m.Attach(mockCopyFile("benchmark.txt"))
819 | m.Embed(mockCopyFile("benchmark.jpg"))
820 |
821 | if err := Send(discardFunc, m); err != nil {
822 | panic(err)
823 | }
824 | m.Reset()
825 | }
826 | }
827 |
--------------------------------------------------------------------------------
/mime.go:
--------------------------------------------------------------------------------
1 | // +build go1.5
2 |
3 | package mail
4 |
5 | import (
6 | "mime"
7 | "mime/quotedprintable"
8 | "strings"
9 | )
10 |
11 | var newQPWriter = quotedprintable.NewWriter
12 |
13 | type mimeEncoder struct {
14 | mime.WordEncoder
15 | }
16 |
17 | var (
18 | bEncoding = mimeEncoder{mime.BEncoding}
19 | qEncoding = mimeEncoder{mime.QEncoding}
20 | lastIndexByte = strings.LastIndexByte
21 | )
22 |
--------------------------------------------------------------------------------
/mime_go14.go:
--------------------------------------------------------------------------------
1 | // +build !go1.5
2 |
3 | package mail
4 |
5 | import "gopkg.in/alexcesaro/quotedprintable.v3"
6 |
7 | var newQPWriter = quotedprintable.NewWriter
8 |
9 | type mimeEncoder struct {
10 | quotedprintable.WordEncoder
11 | }
12 |
13 | var (
14 | bEncoding = mimeEncoder{quotedprintable.BEncoding}
15 | qEncoding = mimeEncoder{quotedprintable.QEncoding}
16 | lastIndexByte = func(s string, c byte) int {
17 | for i := len(s) - 1; i >= 0; i-- {
18 |
19 | if s[i] == c {
20 | return i
21 | }
22 | }
23 | return -1
24 | }
25 | )
26 |
--------------------------------------------------------------------------------
/send.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 | stdmail "net/mail"
8 | )
9 |
10 | // Sender is the interface that wraps the Send method.
11 | //
12 | // Send sends an email to the given addresses.
13 | type Sender interface {
14 | Send(from string, to []string, msg io.WriterTo) error
15 | }
16 |
17 | // SendCloser is the interface that groups the Send and Close methods.
18 | type SendCloser interface {
19 | Sender
20 | Close() error
21 | }
22 |
23 | // A SendFunc is a function that sends emails to the given addresses.
24 | //
25 | // The SendFunc type is an adapter to allow the use of ordinary functions as
26 | // email senders. If f is a function with the appropriate signature, SendFunc(f)
27 | // is a Sender object that calls f.
28 | type SendFunc func(from string, to []string, msg io.WriterTo) error
29 |
30 | // Send calls f(from, to, msg).
31 | func (f SendFunc) Send(from string, to []string, msg io.WriterTo) error {
32 | return f(from, to, msg)
33 | }
34 |
35 | // Send sends emails using the given Sender.
36 | func Send(s Sender, msg ...*Message) error {
37 | for i, m := range msg {
38 | if err := send(s, m); err != nil {
39 | return &SendError{Cause: err, Index: uint(i)}
40 | }
41 | }
42 |
43 | return nil
44 | }
45 |
46 | func send(s Sender, m *Message) error {
47 | from, err := m.getFrom()
48 | if err != nil {
49 | return err
50 | }
51 |
52 | to, err := m.getRecipients()
53 | if err != nil {
54 | return err
55 | }
56 |
57 | if err := s.Send(from, to, m); err != nil {
58 | return err
59 | }
60 |
61 | return nil
62 | }
63 |
64 | func (m *Message) getFrom() (string, error) {
65 | from := m.header["Sender"]
66 | if len(from) == 0 {
67 | from = m.header["From"]
68 | if len(from) == 0 {
69 | return "", errors.New(`gomail: invalid message, "From" field is absent`)
70 | }
71 | }
72 |
73 | return parseAddress(from[0])
74 | }
75 |
76 | func (m *Message) getRecipients() ([]string, error) {
77 | n := 0
78 | for _, field := range []string{"To", "Cc", "Bcc"} {
79 | if addresses, ok := m.header[field]; ok {
80 | n += len(addresses)
81 | }
82 | }
83 | list := make([]string, 0, n)
84 |
85 | for _, field := range []string{"To", "Cc", "Bcc"} {
86 | if addresses, ok := m.header[field]; ok {
87 | for _, a := range addresses {
88 | addr, err := parseAddress(a)
89 | if err != nil {
90 | return nil, err
91 | }
92 | list = addAddress(list, addr)
93 | }
94 | }
95 | }
96 |
97 | return list, nil
98 | }
99 |
100 | func addAddress(list []string, addr string) []string {
101 | for _, a := range list {
102 | if addr == a {
103 | return list
104 | }
105 | }
106 |
107 | return append(list, addr)
108 | }
109 |
110 | func parseAddress(field string) (string, error) {
111 | addr, err := stdmail.ParseAddress(field)
112 | if err != nil {
113 | return "", fmt.Errorf("gomail: invalid address %q: %v", field, err)
114 | }
115 | return addr.Address, nil
116 | }
117 |
--------------------------------------------------------------------------------
/send_test.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "io"
7 | "reflect"
8 | "testing"
9 | )
10 |
11 | const (
12 | testTo1 = "to1@example.com"
13 | testTo2 = "to2@example.com"
14 | testFrom = "from@example.com"
15 | testBody = "Test message"
16 | testMsg = "To: " + testTo1 + ", " + testTo2 + "\r\n" +
17 | "From: " + testFrom + "\r\n" +
18 | "MIME-Version: 1.0\r\n" +
19 | "Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" +
20 | "Content-Type: text/plain; charset=UTF-8\r\n" +
21 | "Content-Transfer-Encoding: quoted-printable\r\n" +
22 | "\r\n" +
23 | testBody
24 | )
25 |
26 | type mockSender SendFunc
27 |
28 | func (s mockSender) Send(from string, to []string, msg io.WriterTo) error {
29 | return s(from, to, msg)
30 | }
31 |
32 | type mockSendCloser struct {
33 | mockSender
34 | close func() error
35 | }
36 |
37 | func (s *mockSendCloser) Close() error {
38 | return s.close()
39 | }
40 |
41 | func TestSend(t *testing.T) {
42 | s := &mockSendCloser{
43 | mockSender: stubSend(t, testFrom, []string{testTo1, testTo2}, testMsg),
44 | close: func() error {
45 | t.Error("Close() should not be called in Send()")
46 | return nil
47 | },
48 | }
49 | if err := Send(s, getTestMessage()); err != nil {
50 | t.Errorf("Send(): %v", err)
51 | }
52 | }
53 |
54 | func TestSendError(t *testing.T) {
55 | s := &mockSendCloser{
56 | mockSender: func(_ string, _ []string, _ io.WriterTo) error {
57 | return errors.New("kaboom")
58 | },
59 | }
60 | wantErr := "gomail: could not send email 1: kaboom"
61 | if err := Send(s, getTestMessage()); err == nil || err.Error() != wantErr {
62 | t.Errorf("expected Send() error, got %q, want %q", err, wantErr)
63 | }
64 | }
65 |
66 | func getTestMessage() *Message {
67 | m := NewMessage()
68 | m.SetHeader("From", testFrom)
69 | m.SetHeader("To", testTo1, testTo2)
70 | m.SetBody("text/plain", testBody)
71 |
72 | return m
73 | }
74 |
75 | func stubSend(t *testing.T, wantFrom string, wantTo []string, wantBody string) mockSender {
76 | return func(from string, to []string, msg io.WriterTo) error {
77 | if from != wantFrom {
78 | t.Errorf("invalid from, got %q, want %q", from, wantFrom)
79 | }
80 | if !reflect.DeepEqual(to, wantTo) {
81 | t.Errorf("invalid to, got %v, want %v", to, wantTo)
82 | }
83 |
84 | buf := new(bytes.Buffer)
85 | _, err := msg.WriteTo(buf)
86 | if err != nil {
87 | t.Fatal(err)
88 | }
89 | compareBodies(t, buf.String(), wantBody)
90 |
91 | return nil
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/smtp.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "io"
7 | "net"
8 | "net/smtp"
9 | "strings"
10 | "time"
11 | )
12 |
13 | // A Dialer is a dialer to an SMTP server.
14 | type Dialer struct {
15 | // Host represents the host of the SMTP server.
16 | Host string
17 | // Port represents the port of the SMTP server.
18 | Port int
19 | // Username is the username to use to authenticate to the SMTP server.
20 | Username string
21 | // Password is the password to use to authenticate to the SMTP server.
22 | Password string
23 | // Auth represents the authentication mechanism used to authenticate to the
24 | // SMTP server.
25 | Auth smtp.Auth
26 | // SSL defines whether an SSL connection is used. It should be false in
27 | // most cases since the authentication mechanism should use the STARTTLS
28 | // extension instead.
29 | SSL bool
30 | // TLSConfig represents the TLS configuration used for the TLS (when the
31 | // STARTTLS extension is used) or SSL connection.
32 | TLSConfig *tls.Config
33 | // StartTLSPolicy represents the TLS security level required to
34 | // communicate with the SMTP server.
35 | //
36 | // This defaults to OpportunisticStartTLS for backwards compatibility,
37 | // but we recommend MandatoryStartTLS for all modern SMTP servers.
38 | //
39 | // This option has no effect if SSL is set to true.
40 | StartTLSPolicy StartTLSPolicy
41 | // LocalName is the hostname sent to the SMTP server with the HELO command.
42 | // By default, "localhost" is sent.
43 | LocalName string
44 | // Timeout to use for read/write operations. Defaults to 10 seconds, can
45 | // be set to 0 to disable timeouts.
46 | Timeout time.Duration
47 | // Whether we should retry mailing if the connection returned an error,
48 | // defaults to true.
49 | RetryFailure bool
50 | }
51 |
52 | // NewDialer returns a new SMTP Dialer. The given parameters are used to connect
53 | // to the SMTP server.
54 | func NewDialer(host string, port int, username, password string) *Dialer {
55 | return &Dialer{
56 | Host: host,
57 | Port: port,
58 | Username: username,
59 | Password: password,
60 | SSL: port == 465,
61 | Timeout: 10 * time.Second,
62 | RetryFailure: true,
63 | }
64 | }
65 |
66 | // NewPlainDialer returns a new SMTP Dialer. The given parameters are used to
67 | // connect to the SMTP server.
68 | //
69 | // Deprecated: Use NewDialer instead.
70 | func NewPlainDialer(host string, port int, username, password string) *Dialer {
71 | return NewDialer(host, port, username, password)
72 | }
73 |
74 | // NetDialTimeout specifies the DialTimeout function to establish a connection
75 | // to the SMTP server. This can be used to override dialing in the case that a
76 | // proxy or other special behavior is needed.
77 | var NetDialTimeout = net.DialTimeout
78 |
79 | // Dial dials and authenticates to an SMTP server. The returned SendCloser
80 | // should be closed when done using it.
81 | func (d *Dialer) Dial() (SendCloser, error) {
82 | conn, err := NetDialTimeout("tcp", addr(d.Host, d.Port), d.Timeout)
83 | if err != nil {
84 | return nil, err
85 | }
86 |
87 | if d.SSL {
88 | conn = tlsClient(conn, d.tlsConfig())
89 | }
90 |
91 | c, err := smtpNewClient(conn, d.Host)
92 | if err != nil {
93 | return nil, err
94 | }
95 |
96 | if d.Timeout > 0 {
97 | conn.SetDeadline(time.Now().Add(d.Timeout))
98 | }
99 |
100 | if d.LocalName != "" {
101 | if err := c.Hello(d.LocalName); err != nil {
102 | return nil, err
103 | }
104 | }
105 |
106 | if !d.SSL && d.StartTLSPolicy != NoStartTLS {
107 | ok, _ := c.Extension("STARTTLS")
108 | if !ok && d.StartTLSPolicy == MandatoryStartTLS {
109 | err := StartTLSUnsupportedError{
110 | Policy: d.StartTLSPolicy}
111 | return nil, err
112 | }
113 |
114 | if ok {
115 | if err := c.StartTLS(d.tlsConfig()); err != nil {
116 | c.Close()
117 | return nil, err
118 | }
119 | }
120 | }
121 |
122 | if d.Auth == nil && d.Username != "" {
123 | if ok, auths := c.Extension("AUTH"); ok {
124 | if strings.Contains(auths, "CRAM-MD5") {
125 | d.Auth = smtp.CRAMMD5Auth(d.Username, d.Password)
126 | } else if strings.Contains(auths, "LOGIN") &&
127 | !strings.Contains(auths, "PLAIN") {
128 | d.Auth = &loginAuth{
129 | username: d.Username,
130 | password: d.Password,
131 | host: d.Host,
132 | }
133 | } else {
134 | d.Auth = smtp.PlainAuth("", d.Username, d.Password, d.Host)
135 | }
136 | }
137 | }
138 |
139 | if d.Auth != nil {
140 | if err = c.Auth(d.Auth); err != nil {
141 | c.Close()
142 | return nil, err
143 | }
144 | }
145 |
146 | return &smtpSender{c, conn, d}, nil
147 | }
148 |
149 | func (d *Dialer) tlsConfig() *tls.Config {
150 | if d.TLSConfig == nil {
151 | return &tls.Config{ServerName: d.Host}
152 | }
153 | return d.TLSConfig
154 | }
155 |
156 | // StartTLSPolicy constants are valid values for Dialer.StartTLSPolicy.
157 | type StartTLSPolicy int
158 |
159 | const (
160 | // OpportunisticStartTLS means that SMTP transactions are encrypted if
161 | // STARTTLS is supported by the SMTP server. Otherwise, messages are
162 | // sent in the clear. This is the default setting.
163 | OpportunisticStartTLS StartTLSPolicy = iota
164 | // MandatoryStartTLS means that SMTP transactions must be encrypted.
165 | // SMTP transactions are aborted unless STARTTLS is supported by the
166 | // SMTP server.
167 | MandatoryStartTLS
168 | // NoStartTLS means encryption is disabled and messages are sent in the
169 | // clear.
170 | NoStartTLS = -1
171 | )
172 |
173 | func (policy *StartTLSPolicy) String() string {
174 | switch *policy {
175 | case OpportunisticStartTLS:
176 | return "OpportunisticStartTLS"
177 | case MandatoryStartTLS:
178 | return "MandatoryStartTLS"
179 | case NoStartTLS:
180 | return "NoStartTLS"
181 | default:
182 | return fmt.Sprintf("StartTLSPolicy:%v", *policy)
183 | }
184 | }
185 |
186 | // StartTLSUnsupportedError is returned by Dial when connecting to an SMTP
187 | // server that does not support STARTTLS.
188 | type StartTLSUnsupportedError struct {
189 | Policy StartTLSPolicy
190 | }
191 |
192 | func (e StartTLSUnsupportedError) Error() string {
193 | return "gomail: " + e.Policy.String() + " required, but " +
194 | "SMTP server does not support STARTTLS"
195 | }
196 |
197 | func addr(host string, port int) string {
198 | return fmt.Sprintf("%s:%d", host, port)
199 | }
200 |
201 | // DialAndSend opens a connection to the SMTP server, sends the given emails and
202 | // closes the connection.
203 | func (d *Dialer) DialAndSend(m ...*Message) error {
204 | s, err := d.Dial()
205 | if err != nil {
206 | return err
207 | }
208 | defer s.Close()
209 |
210 | return Send(s, m...)
211 | }
212 |
213 | type smtpSender struct {
214 | smtpClient
215 | conn net.Conn
216 | d *Dialer
217 | }
218 |
219 | func (c *smtpSender) retryError(err error) bool {
220 | if !c.d.RetryFailure {
221 | return false
222 | }
223 |
224 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
225 | return true
226 | }
227 |
228 | return err == io.EOF
229 | }
230 |
231 | func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error {
232 | if c.d.Timeout > 0 {
233 | c.conn.SetDeadline(time.Now().Add(c.d.Timeout))
234 | }
235 |
236 | if err := c.Mail(from); err != nil {
237 | if c.retryError(err) {
238 | // This is probably due to a timeout, so reconnect and try again.
239 | sc, derr := c.d.Dial()
240 | if derr == nil {
241 | if s, ok := sc.(*smtpSender); ok {
242 | *c = *s
243 | return c.Send(from, to, msg)
244 | }
245 | }
246 | }
247 |
248 | return err
249 | }
250 |
251 | for _, addr := range to {
252 | if err := c.Rcpt(addr); err != nil {
253 | return err
254 | }
255 | }
256 |
257 | w, err := c.Data()
258 | if err != nil {
259 | return err
260 | }
261 |
262 | if _, err = msg.WriteTo(w); err != nil {
263 | w.Close()
264 | return err
265 | }
266 |
267 | return w.Close()
268 | }
269 |
270 | func (c *smtpSender) Close() error {
271 | return c.Quit()
272 | }
273 |
274 | // Stubbed out for tests.
275 | var (
276 | tlsClient = tls.Client
277 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) {
278 | return smtp.NewClient(conn, host)
279 | }
280 | )
281 |
282 | type smtpClient interface {
283 | Hello(string) error
284 | Extension(string) (bool, string)
285 | StartTLS(*tls.Config) error
286 | Auth(smtp.Auth) error
287 | Mail(string) error
288 | Rcpt(string) error
289 | Data() (io.WriteCloser, error)
290 | Quit() error
291 | Close() error
292 | }
293 |
--------------------------------------------------------------------------------
/smtp_test.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "bytes"
5 | "crypto/tls"
6 | "io"
7 | "net"
8 | "net/smtp"
9 | "reflect"
10 | "testing"
11 | "time"
12 | )
13 |
14 | const (
15 | testPort = 587
16 | testSSLPort = 465
17 | )
18 |
19 | var (
20 | testConn = &net.TCPConn{}
21 | testTLSConn = tls.Client(testConn, &tls.Config{InsecureSkipVerify: true})
22 | testConfig = &tls.Config{InsecureSkipVerify: true}
23 | testAuth = smtp.PlainAuth("", testUser, testPwd, testHost)
24 | )
25 |
26 | func TestDialer(t *testing.T) {
27 | d := NewDialer(testHost, testPort, "user", "pwd")
28 | testSendMail(t, d, []string{
29 | "Extension STARTTLS",
30 | "StartTLS",
31 | "Extension AUTH",
32 | "Auth",
33 | "Mail " + testFrom,
34 | "Rcpt " + testTo1,
35 | "Rcpt " + testTo2,
36 | "Data",
37 | "Write message",
38 | "Close writer",
39 | "Quit",
40 | "Close",
41 | })
42 | }
43 |
44 | func TestDialerSSL(t *testing.T) {
45 | d := NewDialer(testHost, testSSLPort, "user", "pwd")
46 | testSendMail(t, d, []string{
47 | "Extension AUTH",
48 | "Auth",
49 | "Mail " + testFrom,
50 | "Rcpt " + testTo1,
51 | "Rcpt " + testTo2,
52 | "Data",
53 | "Write message",
54 | "Close writer",
55 | "Quit",
56 | "Close",
57 | })
58 | }
59 |
60 | func TestDialerConfig(t *testing.T) {
61 | d := NewDialer(testHost, testPort, "user", "pwd")
62 | d.LocalName = "test"
63 | d.TLSConfig = testConfig
64 | testSendMail(t, d, []string{
65 | "Hello test",
66 | "Extension STARTTLS",
67 | "StartTLS",
68 | "Extension AUTH",
69 | "Auth",
70 | "Mail " + testFrom,
71 | "Rcpt " + testTo1,
72 | "Rcpt " + testTo2,
73 | "Data",
74 | "Write message",
75 | "Close writer",
76 | "Quit",
77 | "Close",
78 | })
79 | }
80 |
81 | func TestDialerSSLConfig(t *testing.T) {
82 | d := NewDialer(testHost, testSSLPort, "user", "pwd")
83 | d.LocalName = "test"
84 | d.TLSConfig = testConfig
85 | testSendMail(t, d, []string{
86 | "Hello test",
87 | "Extension AUTH",
88 | "Auth",
89 | "Mail " + testFrom,
90 | "Rcpt " + testTo1,
91 | "Rcpt " + testTo2,
92 | "Data",
93 | "Write message",
94 | "Close writer",
95 | "Quit",
96 | "Close",
97 | })
98 | }
99 |
100 | func TestDialerNoStartTLS(t *testing.T) {
101 | d := NewDialer(testHost, testPort, "user", "pwd")
102 | d.StartTLSPolicy = NoStartTLS
103 | testSendMail(t, d, []string{
104 | "Extension AUTH",
105 | "Auth",
106 | "Mail " + testFrom,
107 | "Rcpt " + testTo1,
108 | "Rcpt " + testTo2,
109 | "Data",
110 | "Write message",
111 | "Close writer",
112 | "Quit",
113 | "Close",
114 | })
115 | }
116 |
117 | func TestDialerOpportunisticStartTLS(t *testing.T) {
118 | d := NewDialer(testHost, testPort, "user", "pwd")
119 | d.StartTLSPolicy = OpportunisticStartTLS
120 | testSendMail(t, d, []string{
121 | "Extension STARTTLS",
122 | "StartTLS",
123 | "Extension AUTH",
124 | "Auth",
125 | "Mail " + testFrom,
126 | "Rcpt " + testTo1,
127 | "Rcpt " + testTo2,
128 | "Data",
129 | "Write message",
130 | "Close writer",
131 | "Quit",
132 | "Close",
133 | })
134 |
135 | if OpportunisticStartTLS != 0 {
136 | t.Errorf("OpportunisticStartTLS: expected 0, got %d",
137 | OpportunisticStartTLS)
138 | }
139 | }
140 |
141 | func TestDialerOpportunisticStartTLSUnsupported(t *testing.T) {
142 | d := NewDialer(testHost, testPort, "user", "pwd")
143 | d.StartTLSPolicy = OpportunisticStartTLS
144 | testSendMailStartTLSUnsupported(t, d, []string{
145 | "Extension STARTTLS",
146 | "Extension AUTH",
147 | "Auth",
148 | "Mail " + testFrom,
149 | "Rcpt " + testTo1,
150 | "Rcpt " + testTo2,
151 | "Data",
152 | "Write message",
153 | "Close writer",
154 | "Quit",
155 | "Close",
156 | })
157 | }
158 |
159 | func TestDialerMandatoryStartTLS(t *testing.T) {
160 | d := NewDialer(testHost, testPort, "user", "pwd")
161 | d.StartTLSPolicy = MandatoryStartTLS
162 | testSendMail(t, d, []string{
163 | "Extension STARTTLS",
164 | "StartTLS",
165 | "Extension AUTH",
166 | "Auth",
167 | "Mail " + testFrom,
168 | "Rcpt " + testTo1,
169 | "Rcpt " + testTo2,
170 | "Data",
171 | "Write message",
172 | "Close writer",
173 | "Quit",
174 | "Close",
175 | })
176 | }
177 |
178 | func TestDialerMandatoryStartTLSUnsupported(t *testing.T) {
179 | d := NewDialer(testHost, testPort, "user", "pwd")
180 | d.StartTLSPolicy = MandatoryStartTLS
181 |
182 | testClient := &mockClient{
183 | t: t,
184 | addr: addr(d.Host, d.Port),
185 | config: d.TLSConfig,
186 | startTLS: false,
187 | timeout: true,
188 | }
189 |
190 | err := doTestSendMail(t, d, testClient, []string{
191 | "Extension STARTTLS",
192 | })
193 |
194 | if _, ok := err.(StartTLSUnsupportedError); !ok {
195 | t.Errorf("expected StartTLSUnsupportedError, but got: %s",
196 | reflect.TypeOf(err).Name())
197 | }
198 |
199 | expected := "gomail: MandatoryStartTLS required, " +
200 | "but SMTP server does not support STARTTLS"
201 | if err.Error() != expected {
202 | t.Errorf("expected %s, but got: %s", expected, err)
203 | }
204 | }
205 |
206 | func TestDialerNoAuth(t *testing.T) {
207 | d := &Dialer{
208 | Host: testHost,
209 | Port: testPort,
210 | }
211 | testSendMail(t, d, []string{
212 | "Extension STARTTLS",
213 | "StartTLS",
214 | "Mail " + testFrom,
215 | "Rcpt " + testTo1,
216 | "Rcpt " + testTo2,
217 | "Data",
218 | "Write message",
219 | "Close writer",
220 | "Quit",
221 | "Close",
222 | })
223 | }
224 |
225 | func TestDialerTimeout(t *testing.T) {
226 | d := &Dialer{
227 | Host: testHost,
228 | Port: testPort,
229 | RetryFailure: true,
230 | }
231 | testSendMailTimeout(t, d, []string{
232 | "Extension STARTTLS",
233 | "StartTLS",
234 | "Mail " + testFrom,
235 | "Extension STARTTLS",
236 | "StartTLS",
237 | "Mail " + testFrom,
238 | "Rcpt " + testTo1,
239 | "Rcpt " + testTo2,
240 | "Data",
241 | "Write message",
242 | "Close writer",
243 | "Quit",
244 | "Close",
245 | })
246 | }
247 |
248 | func TestDialerTimeoutNoRetry(t *testing.T) {
249 | d := &Dialer{
250 | Host: testHost,
251 | Port: testPort,
252 | RetryFailure: false,
253 | }
254 | testClient := &mockClient{
255 | t: t,
256 | addr: addr(d.Host, d.Port),
257 | config: d.TLSConfig,
258 | startTLS: true,
259 | timeout: true,
260 | }
261 |
262 | err := doTestSendMail(t, d, testClient, []string{
263 | "Extension STARTTLS",
264 | "StartTLS",
265 | "Mail " + testFrom,
266 | "Quit",
267 | })
268 |
269 | if err.Error() != "gomail: could not send email 1: EOF" {
270 | t.Error("expected to have got EOF, but got:", err)
271 | }
272 | }
273 |
274 | type mockClient struct {
275 | t *testing.T
276 | i int
277 | want []string
278 | addr string
279 | config *tls.Config
280 | startTLS bool
281 | timeout bool
282 | }
283 |
284 | func (c *mockClient) Hello(localName string) error {
285 | c.do("Hello " + localName)
286 | return nil
287 | }
288 |
289 | func (c *mockClient) Extension(ext string) (bool, string) {
290 | c.do("Extension " + ext)
291 | ok := true
292 | if ext == "STARTTLS" {
293 | ok = c.startTLS
294 | }
295 | return ok, ""
296 | }
297 |
298 | func (c *mockClient) StartTLS(config *tls.Config) error {
299 | assertConfig(c.t, config, c.config)
300 | c.do("StartTLS")
301 | return nil
302 | }
303 |
304 | func (c *mockClient) Auth(a smtp.Auth) error {
305 | if !reflect.DeepEqual(a, testAuth) {
306 | c.t.Errorf("Invalid auth, got %#v, want %#v", a, testAuth)
307 | }
308 | c.do("Auth")
309 | return nil
310 | }
311 |
312 | func (c *mockClient) Mail(from string) error {
313 | c.do("Mail " + from)
314 | if c.timeout {
315 | c.timeout = false
316 | return io.EOF
317 | }
318 | return nil
319 | }
320 |
321 | func (c *mockClient) Rcpt(to string) error {
322 | c.do("Rcpt " + to)
323 | return nil
324 | }
325 |
326 | func (c *mockClient) Data() (io.WriteCloser, error) {
327 | c.do("Data")
328 | return &mockWriter{c: c, want: testMsg}, nil
329 | }
330 |
331 | func (c *mockClient) Quit() error {
332 | c.do("Quit")
333 | return nil
334 | }
335 |
336 | func (c *mockClient) Close() error {
337 | c.do("Close")
338 | return nil
339 | }
340 |
341 | func (c *mockClient) do(cmd string) {
342 | if c.i >= len(c.want) {
343 | c.t.Fatalf("Invalid command %q", cmd)
344 | }
345 |
346 | if cmd != c.want[c.i] {
347 | c.t.Fatalf("Invalid command, got %q, want %q", cmd, c.want[c.i])
348 | }
349 | c.i++
350 | }
351 |
352 | type mockWriter struct {
353 | want string
354 | c *mockClient
355 | buf bytes.Buffer
356 | }
357 |
358 | func (w *mockWriter) Write(p []byte) (int, error) {
359 | if w.buf.Len() == 0 {
360 | w.c.do("Write message")
361 | }
362 | w.buf.Write(p)
363 | return len(p), nil
364 | }
365 |
366 | func (w *mockWriter) Close() error {
367 | compareBodies(w.c.t, w.buf.String(), w.want)
368 | w.c.do("Close writer")
369 | return nil
370 | }
371 |
372 | func testSendMail(t *testing.T, d *Dialer, want []string) {
373 | testClient := &mockClient{
374 | t: t,
375 | addr: addr(d.Host, d.Port),
376 | config: d.TLSConfig,
377 | startTLS: true,
378 | timeout: false,
379 | }
380 |
381 | if err := doTestSendMail(t, d, testClient, want); err != nil {
382 | t.Error(err)
383 | }
384 | }
385 |
386 | func testSendMailStartTLSUnsupported(t *testing.T, d *Dialer, want []string) {
387 | testClient := &mockClient{
388 | t: t,
389 | addr: addr(d.Host, d.Port),
390 | config: d.TLSConfig,
391 | startTLS: false,
392 | timeout: false,
393 | }
394 |
395 | if err := doTestSendMail(t, d, testClient, want); err != nil {
396 | t.Error(err)
397 | }
398 | }
399 |
400 | func testSendMailTimeout(t *testing.T, d *Dialer, want []string) {
401 | testClient := &mockClient{
402 | t: t,
403 | addr: addr(d.Host, d.Port),
404 | config: d.TLSConfig,
405 | startTLS: true,
406 | timeout: true,
407 | }
408 |
409 | if err := doTestSendMail(t, d, testClient, want); err != nil {
410 | t.Error(err)
411 | }
412 | }
413 |
414 | func doTestSendMail(t *testing.T, d *Dialer, testClient *mockClient, want []string) error {
415 | testClient.want = want
416 |
417 | NetDialTimeout = func(network, address string, d time.Duration) (net.Conn, error) {
418 | if network != "tcp" {
419 | t.Errorf("Invalid network, got %q, want tcp", network)
420 | }
421 | if address != testClient.addr {
422 | t.Errorf("Invalid address, got %q, want %q",
423 | address, testClient.addr)
424 | }
425 | return testConn, nil
426 | }
427 |
428 | tlsClient = func(conn net.Conn, config *tls.Config) *tls.Conn {
429 | if conn != testConn {
430 | t.Errorf("Invalid conn, got %#v, want %#v", conn, testConn)
431 | }
432 | assertConfig(t, config, testClient.config)
433 | return testTLSConn
434 | }
435 |
436 | smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) {
437 | if host != testHost {
438 | t.Errorf("Invalid host, got %q, want %q", host, testHost)
439 | }
440 | return testClient, nil
441 | }
442 |
443 | return d.DialAndSend(getTestMessage())
444 | }
445 |
446 | func assertConfig(t *testing.T, got, want *tls.Config) {
447 | if want == nil {
448 | want = &tls.Config{ServerName: testHost}
449 | }
450 | if got.ServerName != want.ServerName {
451 | t.Errorf("Invalid field ServerName in config, got %q, want %q", got.ServerName, want.ServerName)
452 | }
453 | if got.InsecureSkipVerify != want.InsecureSkipVerify {
454 | t.Errorf("Invalid field InsecureSkipVerify in config, got %v, want %v", got.InsecureSkipVerify, want.InsecureSkipVerify)
455 | }
456 | }
457 |
--------------------------------------------------------------------------------
/writeto.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "encoding/base64"
5 | "errors"
6 | "io"
7 | "mime"
8 | "mime/multipart"
9 | "path/filepath"
10 | "strings"
11 | "time"
12 | )
13 |
14 | // WriteTo implements io.WriterTo. It dumps the whole message into w.
15 | func (m *Message) WriteTo(w io.Writer) (int64, error) {
16 | mw := &messageWriter{w: w}
17 | mw.writeMessage(m)
18 | return mw.n, mw.err
19 | }
20 |
21 | func (w *messageWriter) writeMessage(m *Message) {
22 | if _, ok := m.header["MIME-Version"]; !ok {
23 | w.writeString("MIME-Version: 1.0\r\n")
24 | }
25 | if _, ok := m.header["Date"]; !ok {
26 | w.writeHeader("Date", m.FormatDate(now()))
27 | }
28 | w.writeHeaders(m.header)
29 |
30 | if m.hasMixedPart() {
31 | w.openMultipart("mixed", m.boundary)
32 | }
33 |
34 | if m.hasRelatedPart() {
35 | w.openMultipart("related", m.boundary)
36 | }
37 |
38 | if m.hasAlternativePart() {
39 | w.openMultipart("alternative", m.boundary)
40 | }
41 | for _, part := range m.parts {
42 | w.writePart(part, m.charset)
43 | }
44 | if m.hasAlternativePart() {
45 | w.closeMultipart()
46 | }
47 |
48 | w.addFiles(m.embedded, false)
49 | if m.hasRelatedPart() {
50 | w.closeMultipart()
51 | }
52 |
53 | w.addFiles(m.attachments, true)
54 | if m.hasMixedPart() {
55 | w.closeMultipart()
56 | }
57 | }
58 |
59 | func (m *Message) hasMixedPart() bool {
60 | return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1
61 | }
62 |
63 | func (m *Message) hasRelatedPart() bool {
64 | return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1
65 | }
66 |
67 | func (m *Message) hasAlternativePart() bool {
68 | return len(m.parts) > 1
69 | }
70 |
71 | type messageWriter struct {
72 | w io.Writer
73 | n int64
74 | writers [3]*multipart.Writer
75 | partWriter io.Writer
76 | depth uint8
77 | err error
78 | }
79 |
80 | func (w *messageWriter) openMultipart(mimeType, boundary string) {
81 | mw := multipart.NewWriter(w)
82 | if boundary != "" {
83 | mw.SetBoundary(boundary)
84 | }
85 | contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary()
86 | w.writers[w.depth] = mw
87 |
88 | if w.depth == 0 {
89 | w.writeHeader("Content-Type", contentType)
90 | w.writeString("\r\n")
91 | } else {
92 | w.createPart(map[string][]string{
93 | "Content-Type": {contentType},
94 | })
95 | }
96 | w.depth++
97 | }
98 |
99 | func (w *messageWriter) createPart(h map[string][]string) {
100 | w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h)
101 | }
102 |
103 | func (w *messageWriter) closeMultipart() {
104 | if w.depth > 0 {
105 | w.writers[w.depth-1].Close()
106 | w.depth--
107 | }
108 | }
109 |
110 | func (w *messageWriter) writePart(p *part, charset string) {
111 | w.writeHeaders(map[string][]string{
112 | "Content-Type": {p.contentType + "; charset=" + charset},
113 | "Content-Transfer-Encoding": {string(p.encoding)},
114 | })
115 | w.writeBody(p.copier, p.encoding)
116 | }
117 |
118 | func (w *messageWriter) addFiles(files []*file, isAttachment bool) {
119 | for _, f := range files {
120 | if _, ok := f.Header["Content-Type"]; !ok {
121 | mediaType := mime.TypeByExtension(filepath.Ext(f.Name))
122 | if mediaType == "" {
123 | mediaType = "application/octet-stream"
124 | }
125 | f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`)
126 | }
127 |
128 | if _, ok := f.Header["Content-Transfer-Encoding"]; !ok {
129 | f.setHeader("Content-Transfer-Encoding", string(Base64))
130 | }
131 |
132 | if _, ok := f.Header["Content-Disposition"]; !ok {
133 | var disp string
134 | if isAttachment {
135 | disp = "attachment"
136 | } else {
137 | disp = "inline"
138 | }
139 | f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`)
140 | }
141 |
142 | if !isAttachment {
143 | if _, ok := f.Header["Content-ID"]; !ok {
144 | f.setHeader("Content-ID", "<"+f.Name+">")
145 | }
146 | }
147 | w.writeHeaders(f.Header)
148 | w.writeBody(f.CopyFunc, Base64)
149 | }
150 | }
151 |
152 | func (w *messageWriter) Write(p []byte) (int, error) {
153 | if w.err != nil {
154 | return 0, errors.New("gomail: cannot write as writer is in error")
155 | }
156 |
157 | var n int
158 | n, w.err = w.w.Write(p)
159 | w.n += int64(n)
160 | return n, w.err
161 | }
162 |
163 | func (w *messageWriter) writeString(s string) {
164 | if w.err != nil { // do nothing when in error
165 | return
166 | }
167 | var n int
168 | n, w.err = io.WriteString(w.w, s)
169 | w.n += int64(n)
170 | }
171 |
172 | func (w *messageWriter) writeHeader(k string, v ...string) {
173 | w.writeString(k)
174 | if len(v) == 0 {
175 | w.writeString(":\r\n")
176 | return
177 | }
178 | w.writeString(": ")
179 |
180 | // Max header line length is 78 characters in RFC 5322 and 76 characters
181 | // in RFC 2047. So for the sake of simplicity we use the 76 characters
182 | // limit.
183 | charsLeft := 76 - len(k) - len(": ")
184 |
185 | for i, s := range v {
186 | // If the line is already too long, insert a newline right away.
187 | if charsLeft < 1 {
188 | if i == 0 {
189 | w.writeString("\r\n ")
190 | } else {
191 | w.writeString(",\r\n ")
192 | }
193 | charsLeft = 75
194 | } else if i != 0 {
195 | w.writeString(", ")
196 | charsLeft -= 2
197 | }
198 |
199 | // While the header content is too long, fold it by inserting a newline.
200 | for len(s) > charsLeft {
201 | s = w.writeLine(s, charsLeft)
202 | charsLeft = 75
203 | }
204 | w.writeString(s)
205 | if i := lastIndexByte(s, '\n'); i != -1 {
206 | charsLeft = 75 - (len(s) - i - 1)
207 | } else {
208 | charsLeft -= len(s)
209 | }
210 | }
211 | w.writeString("\r\n")
212 | }
213 |
214 | func (w *messageWriter) writeLine(s string, charsLeft int) string {
215 | // If there is already a newline before the limit. Write the line.
216 | if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft {
217 | w.writeString(s[:i+1])
218 | return s[i+1:]
219 | }
220 |
221 | for i := charsLeft - 1; i >= 0; i-- {
222 | if s[i] == ' ' {
223 | w.writeString(s[:i])
224 | w.writeString("\r\n ")
225 | return s[i+1:]
226 | }
227 | }
228 |
229 | // We could not insert a newline cleanly so look for a space or a newline
230 | // even if it is after the limit.
231 | for i := 75; i < len(s); i++ {
232 | if s[i] == ' ' {
233 | w.writeString(s[:i])
234 | w.writeString("\r\n ")
235 | return s[i+1:]
236 | }
237 | if s[i] == '\n' {
238 | w.writeString(s[:i+1])
239 | return s[i+1:]
240 | }
241 | }
242 |
243 | // Too bad, no space or newline in the whole string. Just write everything.
244 | w.writeString(s)
245 | return ""
246 | }
247 |
248 | func (w *messageWriter) writeHeaders(h map[string][]string) {
249 | if w.depth == 0 {
250 | for k, v := range h {
251 | if k != "Bcc" {
252 | w.writeHeader(k, v...)
253 | }
254 | }
255 | } else {
256 | w.createPart(h)
257 | }
258 | }
259 |
260 | func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) {
261 | var subWriter io.Writer
262 | if w.depth == 0 {
263 | w.writeString("\r\n")
264 | subWriter = w.w
265 | } else {
266 | subWriter = w.partWriter
267 | }
268 |
269 | if enc == Base64 {
270 | wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter))
271 | w.err = f(wc)
272 | wc.Close()
273 | } else if enc == Unencoded {
274 | w.err = f(subWriter)
275 | } else {
276 | wc := newQPWriter(subWriter)
277 | w.err = f(wc)
278 | wc.Close()
279 | }
280 | }
281 |
282 | // As required by RFC 2045, 6.7. (page 21) for quoted-printable, and
283 | // RFC 2045, 6.8. (page 25) for base64.
284 | const maxLineLen = 76
285 |
286 | // base64LineWriter limits text encoded in base64 to 76 characters per line
287 | type base64LineWriter struct {
288 | w io.Writer
289 | lineLen int
290 | }
291 |
292 | func newBase64LineWriter(w io.Writer) *base64LineWriter {
293 | return &base64LineWriter{w: w}
294 | }
295 |
296 | func (w *base64LineWriter) Write(p []byte) (int, error) {
297 | n := 0
298 | for len(p)+w.lineLen > maxLineLen {
299 | w.w.Write(p[:maxLineLen-w.lineLen])
300 | w.w.Write([]byte("\r\n"))
301 | p = p[maxLineLen-w.lineLen:]
302 | n += maxLineLen - w.lineLen
303 | w.lineLen = 0
304 | }
305 |
306 | w.w.Write(p)
307 | w.lineLen += len(p)
308 |
309 | return n + len(p), nil
310 | }
311 |
312 | // Stubbed out for testing.
313 | var now = time.Now
314 |
--------------------------------------------------------------------------------