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