mirror of https://github.com/dexidp/dex.git
25 changed files with 2601 additions and 15 deletions
@ -0,0 +1,52 @@
|
||||
# Configuring Sending Emails |
||||
|
||||
Dex sends emails to a during the registration process to verify an email |
||||
address belongs to the person signing up. Currently Dex supports two ways of |
||||
sending emails, and has a third option for use during development. |
||||
|
||||
Configuration of the email provider in Dex is provided through a JSON file. All |
||||
email providers have a `type` and `id` field as well as some additional provider |
||||
specific fields. |
||||
|
||||
## SMTP |
||||
|
||||
If using SMTP the `type` field **must** be set to `smtp`. Additionally both |
||||
`host` and `port` are required. If you wish to use SMTP plain auth, then |
||||
set `auth` to `plain` and specify your username and password. |
||||
|
||||
``` |
||||
{ |
||||
"type": "smtp", |
||||
"host": "smtp.example.org", |
||||
"port": 587, |
||||
"auth": "plain", |
||||
"username": "postmaster@example.org", |
||||
"password": "foo" |
||||
} |
||||
``` |
||||
|
||||
## Mailgun |
||||
|
||||
If using Mailgun the `type` field **must** be set to `mailgun`. Additionally |
||||
`privateAPIKey`, `publicAPIKey`, and `domain` are required. |
||||
|
||||
``` |
||||
{ |
||||
"type": "mailgun", |
||||
"privateAPIKey": "key-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", |
||||
"publicAPIKey": "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", |
||||
"domain": "sandboxZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ.mailgun.org" |
||||
} |
||||
``` |
||||
|
||||
## Dev |
||||
|
||||
The fake emailer should only be used in development. The fake emailer |
||||
prints emails to `stdout` rather than sending any email. If using the fake |
||||
emailer the `type` field **must** be set to `fake`. |
||||
|
||||
``` |
||||
{ |
||||
"type": "fake" |
||||
} |
||||
``` |
||||
@ -0,0 +1,8 @@
|
||||
language: go |
||||
|
||||
go: |
||||
- 1.2 |
||||
- 1.3 |
||||
- 1.4 |
||||
- 1.5 |
||||
- tip |
||||
@ -0,0 +1,20 @@
|
||||
# Change Log |
||||
All notable changes to this project will be documented in this file. |
||||
This project adheres to [Semantic Versioning](http://semver.org/). |
||||
|
||||
## [2.0.0] - 2015-09-02 |
||||
|
||||
- Mailer has been removed. It has been replaced by Dialer and Sender. |
||||
- `File` type and the `CreateFile` and `OpenFile` functions have been removed. |
||||
- `Message.Attach` and `Message.Embed` have a new signature. |
||||
- `Message.GetBodyWriter` has been removed. Use `Message.AddAlternativeWriter` |
||||
instead. |
||||
- `Message.Export` has been removed. `Message.WriteTo` can be used instead. |
||||
- `Message.DelHeader` has been removed. |
||||
- The `Bcc` header field is no longer sent. It is far more simpler and |
||||
efficient: the same message is sent to all recipients instead of sending a |
||||
different email to each Bcc address. |
||||
- LoginAuth has been removed. `NewPlainDialer` now implements the LOGIN |
||||
authentication mechanism when needed. |
||||
- Go 1.2 is now required instead of Go 1.3. No external dependency are used when |
||||
using Go 1.5. |
||||
@ -0,0 +1,20 @@
|
||||
Thank you for contributing to Gomail! Here are a few guidelines: |
||||
|
||||
## Bugs |
||||
|
||||
If you think you found a bug, create an issue and supply the minimum amount |
||||
of code triggering the bug so it can be reproduced. |
||||
|
||||
|
||||
## Fixing a bug |
||||
|
||||
If you want to fix a bug, you can send a pull request. It should contains a |
||||
new test or update an existing one to cover that bug. |
||||
|
||||
|
||||
## New feature proposal |
||||
|
||||
If you think Gomail lacks a feature, you can open an issue or send a pull |
||||
request. I want to keep Gomail code and API as simple as possible so please |
||||
describe your needs so we can discuss whether this feature should be added to |
||||
Gomail or not. |
||||
@ -0,0 +1,20 @@
|
||||
The MIT License (MIT) |
||||
|
||||
Copyright (c) 2014 Alexandre Cesaro |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of |
||||
this software and associated documentation files (the "Software"), to deal in |
||||
the Software without restriction, including without limitation the rights to |
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
||||
the Software, and to permit persons to whom the Software is furnished to do so, |
||||
subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
||||
@ -0,0 +1,100 @@
|
||||
# Gomail |
||||
[](https://travis-ci.org/go-gomail/gomail) [](http://gocover.io/gopkg.in/gomail.v2) [](https://godoc.org/gopkg.in/gomail.v2) |
||||
|
||||
## Introduction |
||||
|
||||
Gomail is a simple and efficient package to send emails. It is well tested and |
||||
documented. |
||||
|
||||
Gomail can only send emails using an SMTP server. But the API is flexible and it |
||||
is easy to implement other methods for sending emails using a local Postfix, an |
||||
API, etc. |
||||
|
||||
It is versioned using [gopkg.in](https://gopkg.in) so I promise |
||||
they will never be backward incompatible changes within each version. |
||||
|
||||
It requires Go 1.2 or newer. With Go 1.5, no external dependencies are used. |
||||
|
||||
|
||||
## Features |
||||
|
||||
Gomail supports: |
||||
- Attachments |
||||
- Embedded images |
||||
- HTML and text templates |
||||
- Automatic encoding of special characters |
||||
- SSL and TLS |
||||
- Sending multiple emails with the same SMTP connection |
||||
|
||||
|
||||
## Documentation |
||||
|
||||
https://godoc.org/gopkg.in/gomail.v2 |
||||
|
||||
|
||||
## Download |
||||
|
||||
go get gopkg.in/gomail.v2 |
||||
|
||||
|
||||
## Examples |
||||
|
||||
See the [examples in the documentation](https://godoc.org/gopkg.in/gomail.v2#example-package). |
||||
|
||||
|
||||
## FAQ |
||||
|
||||
### x509: certificate signed by unknown authority |
||||
|
||||
If you get this error it means the certificate used by the SMTP server is not |
||||
considered valid by the client running Gomail. As a quick workaround you can |
||||
bypass the verification of the server's certificate chain and host name by using |
||||
`SetTLSConfig`: |
||||
|
||||
d := gomail.NewPlainDialer("smtp.example.com", "user", "123456", 587) |
||||
d.TLSConfig = &tls.Config{InsecureSkipVerify: true} |
||||
|
||||
Note, however, that this is insecure and should not be used in production. |
||||
|
||||
|
||||
## Contribute |
||||
|
||||
Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for |
||||
more info. |
||||
|
||||
|
||||
## Change log |
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md). |
||||
|
||||
|
||||
## License |
||||
|
||||
[MIT](LICENSE) |
||||
|
||||
|
||||
## Contact |
||||
|
||||
You can ask questions on the [Gomail |
||||
thread](https://groups.google.com/d/topic/golang-nuts/jMxZHzvvEVg/discussion) |
||||
in the Go mailing-list. |
||||
|
||||
|
||||
## Support |
||||
|
||||
If you want to support the development of Gomail, I gladly accept donations. |
||||
|
||||
I will give 100% of the money I receive to |
||||
[Enfants, Espoir Du Monde](http://www.eedm.fr/). |
||||
EEDM is a French NGO which helps children in Bangladesh, Cameroun, Haiti, India |
||||
and Madagascar. |
||||
|
||||
All its members are volunteers so its operating costs are only 1.9%. So your |
||||
money will directly help children of these countries. |
||||
|
||||
As an added bonus, your donations will also tip me by lowering my taxes :smile: |
||||
|
||||
I will send an email with the receipt of the donation to EEDM annually to all |
||||
donors. |
||||
|
||||
[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=PYQKC7VFVXCFG) |
||||
@ -0,0 +1,67 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"fmt" |
||||
"net/smtp" |
||||
) |
||||
|
||||
// plainAuth is an smtp.Auth that implements the PLAIN authentication mechanism.
|
||||
// It fallbacks to the LOGIN mechanism if it is the only mechanism advertised
|
||||
// by the server.
|
||||
type plainAuth struct { |
||||
username string |
||||
password string |
||||
host string |
||||
login bool |
||||
} |
||||
|
||||
func (a *plainAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { |
||||
if server.Name != a.host { |
||||
return "", nil, errors.New("gomail: wrong host name") |
||||
} |
||||
|
||||
var plain, login bool |
||||
for _, a := range server.Auth { |
||||
switch a { |
||||
case "PLAIN": |
||||
plain = true |
||||
case "LOGIN": |
||||
login = true |
||||
} |
||||
} |
||||
|
||||
if !server.TLS && !plain && !login { |
||||
return "", nil, errors.New("gomail: unencrypted connection") |
||||
} |
||||
|
||||
if !plain && login { |
||||
a.login = true |
||||
return "LOGIN", nil, nil |
||||
} |
||||
|
||||
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil |
||||
} |
||||
|
||||
func (a *plainAuth) Next(fromServer []byte, more bool) ([]byte, error) { |
||||
if !a.login { |
||||
if more { |
||||
return nil, errors.New("gomail: unexpected server challenge") |
||||
} |
||||
return nil, nil |
||||
} |
||||
|
||||
if !more { |
||||
return nil, nil |
||||
} |
||||
|
||||
switch { |
||||
case bytes.Equal(fromServer, []byte("Username:")): |
||||
return []byte(a.username), nil |
||||
case bytes.Equal(fromServer, []byte("Password:")): |
||||
return []byte(a.password), nil |
||||
default: |
||||
return nil, fmt.Errorf("gomail: unexpected server challenge: %s", fromServer) |
||||
} |
||||
} |
||||
@ -0,0 +1,156 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"net/smtp" |
||||
"testing" |
||||
) |
||||
|
||||
const ( |
||||
testUser = "user" |
||||
testPwd = "pwd" |
||||
testHost = "smtp.example.com" |
||||
) |
||||
|
||||
var testAuth = &plainAuth{ |
||||
username: testUser, |
||||
password: testPwd, |
||||
host: testHost, |
||||
} |
||||
|
||||
type plainAuthTest struct { |
||||
auths []string |
||||
challenges []string |
||||
tls bool |
||||
wantProto string |
||||
wantData []string |
||||
wantError bool |
||||
} |
||||
|
||||
func TestNoAdvertisement(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: false, |
||||
wantProto: "PLAIN", |
||||
wantError: true, |
||||
}) |
||||
} |
||||
|
||||
func TestNoAdvertisementTLS(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: true, |
||||
wantProto: "PLAIN", |
||||
wantData: []string{"\x00" + testUser + "\x00" + testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func TestPlain(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{"PLAIN"}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: false, |
||||
wantProto: "PLAIN", |
||||
wantData: []string{"\x00" + testUser + "\x00" + testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func TestPlainTLS(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{"PLAIN"}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: true, |
||||
wantProto: "PLAIN", |
||||
wantData: []string{"\x00" + testUser + "\x00" + testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func TestPlainAndLogin(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{"PLAIN", "LOGIN"}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: false, |
||||
wantProto: "PLAIN", |
||||
wantData: []string{"\x00" + testUser + "\x00" + testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func TestPlainAndLoginTLS(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{"PLAIN", "LOGIN"}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: true, |
||||
wantProto: "PLAIN", |
||||
wantData: []string{"\x00" + testUser + "\x00" + testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func TestLogin(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{"LOGIN"}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: false, |
||||
wantProto: "LOGIN", |
||||
wantData: []string{"", testUser, testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func TestLoginTLS(t *testing.T) { |
||||
testPlainAuth(t, &plainAuthTest{ |
||||
auths: []string{"LOGIN"}, |
||||
challenges: []string{"Username:", "Password:"}, |
||||
tls: true, |
||||
wantProto: "LOGIN", |
||||
wantData: []string{"", testUser, testPwd}, |
||||
}) |
||||
} |
||||
|
||||
func testPlainAuth(t *testing.T, test *plainAuthTest) { |
||||
auth := &plainAuth{ |
||||
username: testUser, |
||||
password: testPwd, |
||||
host: testHost, |
||||
} |
||||
server := &smtp.ServerInfo{ |
||||
Name: testHost, |
||||
TLS: test.tls, |
||||
Auth: test.auths, |
||||
} |
||||
proto, toServer, err := auth.Start(server) |
||||
if err != nil && !test.wantError { |
||||
t.Fatalf("plainAuth.Start(): %v", err) |
||||
} |
||||
if err != nil && test.wantError { |
||||
return |
||||
} |
||||
if proto != test.wantProto { |
||||
t.Errorf("invalid protocol, got %q, want %q", proto, test.wantProto) |
||||
} |
||||
|
||||
i := 0 |
||||
got := string(toServer) |
||||
if got != test.wantData[i] { |
||||
t.Errorf("Invalid response, got %q, want %q", got, test.wantData[i]) |
||||
} |
||||
|
||||
if proto == "PLAIN" { |
||||
return |
||||
} |
||||
|
||||
for _, challenge := range test.challenges { |
||||
i++ |
||||
if i >= len(test.wantData) { |
||||
t.Fatalf("unexpected challenge: %q", challenge) |
||||
} |
||||
|
||||
toServer, err = auth.Next([]byte(challenge), true) |
||||
if err != nil { |
||||
t.Fatalf("plainAuth.Auth(): %v", err) |
||||
} |
||||
got = string(toServer) |
||||
if got != test.wantData[i] { |
||||
t.Errorf("Invalid response, got %q, want %q", got, test.wantData[i]) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,5 @@
|
||||
// Package gomail provides a simple interface to compose emails and to mail them
|
||||
// efficiently.
|
||||
//
|
||||
// More info on Github: https://github.com/go-gomail/gomail
|
||||
package gomail |
||||
@ -0,0 +1,215 @@
|
||||
package gomail_test |
||||
|
||||
import ( |
||||
"fmt" |
||||
"html/template" |
||||
"io" |
||||
"log" |
||||
"time" |
||||
|
||||
"gopkg.in/gomail.v2" |
||||
) |
||||
|
||||
func Example() { |
||||
m := gomail.NewMessage() |
||||
m.SetHeader("From", "alex@example.com") |
||||
m.SetHeader("To", "bob@example.com", "cora@example.com") |
||||
m.SetAddressHeader("Cc", "dan@example.com", "Dan") |
||||
m.SetHeader("Subject", "Hello!") |
||||
m.SetBody("text/html", "Hello <b>Bob</b> and <i>Cora</i>!") |
||||
m.Attach("/home/Alex/lolcat.jpg") |
||||
|
||||
d := gomail.NewPlainDialer("smtp.example.com", 587, "user", "123456") |
||||
|
||||
// Send the email to Bob, Cora and Dan.
|
||||
if err := d.DialAndSend(m); err != nil { |
||||
panic(err) |
||||
} |
||||
} |
||||
|
||||
// A daemon that listens to a channel and sends all incoming messages.
|
||||
func Example_daemon() { |
||||
ch := make(chan *gomail.Message) |
||||
|
||||
go func() { |
||||
d := gomail.NewPlainDialer("smtp.example.com", 587, "user", "123456") |
||||
|
||||
var s gomail.SendCloser |
||||
var err error |
||||
open := false |
||||
for { |
||||
select { |
||||
case m, ok := <-ch: |
||||
if !ok { |
||||
return |
||||
} |
||||
if !open { |
||||
if s, err = d.Dial(); err != nil { |
||||
panic(err) |
||||
} |
||||
open = true |
||||
} |
||||
if err := gomail.Send(s, m); err != nil { |
||||
log.Print(err) |
||||
} |
||||
// Close the connection to the SMTP server if no email was sent in
|
||||
// the last 30 seconds.
|
||||
case <-time.After(30 * time.Second): |
||||
if open { |
||||
if err := s.Close(); err != nil { |
||||
panic(err) |
||||
} |
||||
open = false |
||||
} |
||||
} |
||||
} |
||||
}() |
||||
|
||||
// Use the channel in your program to send emails.
|
||||
|
||||
// Close the channel to stop the mail daemon.
|
||||
close(ch) |
||||
} |
||||
|
||||
// Efficiently send a customized newsletter to a list of recipients.
|
||||
func Example_newsletter() { |
||||
// The list of recipients.
|
||||
var list []struct { |
||||
Name string |
||||
Address string |
||||
} |
||||
|
||||
d := gomail.NewPlainDialer("smtp.example.com", 587, "user", "123456") |
||||
s, err := d.Dial() |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
|
||||
m := gomail.NewMessage() |
||||
for _, r := range list { |
||||
m.SetHeader("From", "no-reply@example.com") |
||||
m.SetAddressHeader("To", r.Address, r.Name) |
||||
m.SetHeader("Subject", "Newsletter #1") |
||||
m.SetBody("text/html", fmt.Sprintf("Hello %s!", r.Name)) |
||||
|
||||
if err := gomail.Send(s, m); err != nil { |
||||
log.Printf("Could not send email to %q: %v", r.Address, err) |
||||
} |
||||
m.Reset() |
||||
} |
||||
} |
||||
|
||||
// Send an email using a local SMTP server.
|
||||
func Example_noAuth() { |
||||
m := gomail.NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetHeader("Subject", "Hello!") |
||||
m.SetBody("text/plain", "Hello!") |
||||
|
||||
d := gomail.Dialer{Host: "localhost", Port: 587} |
||||
if err := d.DialAndSend(m); err != nil { |
||||
panic(err) |
||||
} |
||||
} |
||||
|
||||
// Send an email using an API or postfix.
|
||||
func Example_noSMTP() { |
||||
m := gomail.NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetHeader("Subject", "Hello!") |
||||
m.SetBody("text/plain", "Hello!") |
||||
|
||||
s := gomail.SendFunc(func(from string, to []string, msg io.WriterTo) error { |
||||
// Implements you email-sending function, for example by calling
|
||||
// an API, or running postfix, etc.
|
||||
fmt.Println("From:", from) |
||||
fmt.Println("To:", to) |
||||
return nil |
||||
}) |
||||
|
||||
if err := gomail.Send(s, m); err != nil { |
||||
panic(err) |
||||
} |
||||
// Output:
|
||||
// From: from@example.com
|
||||
// To: [to@example.com]
|
||||
} |
||||
|
||||
var m *gomail.Message |
||||
|
||||
func ExampleSetCopyFunc() { |
||||
m.Attach("foo.txt", gomail.SetCopyFunc(func(w io.Writer) error { |
||||
_, err := w.Write([]byte("Content of foo.txt")) |
||||
return err |
||||
})) |
||||
} |
||||
|
||||
func ExampleSetHeader() { |
||||
h := map[string][]string{"Content-ID": {"<foo@bar.mail>"}} |
||||
m.Attach("foo.jpg", gomail.SetHeader(h)) |
||||
} |
||||
|
||||
func ExampleMessage_AddAlternative() { |
||||
m.SetBody("text/plain", "Hello!") |
||||
m.AddAlternative("text/html", "<p>Hello!</p>") |
||||
} |
||||
|
||||
func ExampleMessage_AddAlternativeWriter() { |
||||
t := template.Must(template.New("example").Parse("Hello {{.}}!")) |
||||
m.AddAlternativeWriter("text/plain", func(w io.Writer) error { |
||||
return t.Execute(w, "Bob") |
||||
}) |
||||
} |
||||
|
||||
func ExampleMessage_Attach() { |
||||
m.Attach("/tmp/image.jpg") |
||||
} |
||||
|
||||
func ExampleMessage_Embed() { |
||||
m.Embed("/tmp/image.jpg") |
||||
m.SetBody("text/html", `<img src="cid:image.jpg" alt="My image" />`) |
||||
} |
||||
|
||||
func ExampleMessage_FormatAddress() { |
||||
m.SetHeader("To", m.FormatAddress("bob@example.com", "Bob"), m.FormatAddress("cora@example.com", "Cora")) |
||||
} |
||||
|
||||
func ExampleMessage_FormatDate() { |
||||
m.SetHeaders(map[string][]string{ |
||||
"X-Date": {m.FormatDate(time.Now())}, |
||||
}) |
||||
} |
||||
|
||||
func ExampleMessage_SetAddressHeader() { |
||||
m.SetAddressHeader("To", "bob@example.com", "Bob") |
||||
} |
||||
|
||||
func ExampleMessage_SetBody() { |
||||
m.SetBody("text/plain", "Hello!") |
||||
} |
||||
|
||||
func ExampleMessage_SetDateHeader() { |
||||
m.SetDateHeader("X-Date", time.Now()) |
||||
} |
||||
|
||||
func ExampleMessage_SetHeader() { |
||||
m.SetHeader("Subject", "Hello!") |
||||
} |
||||
|
||||
func ExampleMessage_SetHeaders() { |
||||
m.SetHeaders(map[string][]string{ |
||||
"From": {m.FormatAddress("alex@example.com", "Alex")}, |
||||
"To": {"bob@example.com", "cora@example.com"}, |
||||
"Subject": {"Hello"}, |
||||
}) |
||||
} |
||||
|
||||
func ExampleSetCharset() { |
||||
m = gomail.NewMessage(gomail.SetCharset("ISO-8859-1")) |
||||
} |
||||
|
||||
func ExampleSetEncoding() { |
||||
m = gomail.NewMessage(gomail.SetEncoding(gomail.Base64)) |
||||
} |
||||
@ -0,0 +1,302 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"bytes" |
||||
"io" |
||||
"os" |
||||
"path/filepath" |
||||
"time" |
||||
) |
||||
|
||||
// Message represents an email.
|
||||
type Message struct { |
||||
header header |
||||
parts []part |
||||
attachments []*file |
||||
embedded []*file |
||||
charset string |
||||
encoding Encoding |
||||
hEncoder mimeEncoder |
||||
buf bytes.Buffer |
||||
} |
||||
|
||||
type header map[string][]string |
||||
|
||||
type part struct { |
||||
header header |
||||
copier func(io.Writer) error |
||||
} |
||||
|
||||
// NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding
|
||||
// by default.
|
||||
func NewMessage(settings ...MessageSetting) *Message { |
||||
m := &Message{ |
||||
header: make(header), |
||||
charset: "UTF-8", |
||||
encoding: QuotedPrintable, |
||||
} |
||||
|
||||
m.applySettings(settings) |
||||
|
||||
if m.encoding == Base64 { |
||||
m.hEncoder = bEncoding |
||||
} else { |
||||
m.hEncoder = qEncoding |
||||
} |
||||
|
||||
return m |
||||
} |
||||
|
||||
// Reset resets the message so it can be reused. The message keeps its previous
|
||||
// settings so it is in the same state that after a call to NewMessage.
|
||||
func (m *Message) Reset() { |
||||
for k := range m.header { |
||||
delete(m.header, k) |
||||
} |
||||
m.parts = nil |
||||
m.attachments = nil |
||||
m.embedded = nil |
||||
} |
||||
|
||||
func (m *Message) applySettings(settings []MessageSetting) { |
||||
for _, s := range settings { |
||||
s(m) |
||||
} |
||||
} |
||||
|
||||
// A MessageSetting can be used as an argument in NewMessage to configure an
|
||||
// email.
|
||||
type MessageSetting func(m *Message) |
||||
|
||||
// SetCharset is a message setting to set the charset of the email.
|
||||
func SetCharset(charset string) MessageSetting { |
||||
return func(m *Message) { |
||||
m.charset = charset |
||||
} |
||||
} |
||||
|
||||
// SetEncoding is a message setting to set the encoding of the email.
|
||||
func SetEncoding(enc Encoding) MessageSetting { |
||||
return func(m *Message) { |
||||
m.encoding = enc |
||||
} |
||||
} |
||||
|
||||
// Encoding represents a MIME encoding scheme like quoted-printable or base64.
|
||||
type Encoding string |
||||
|
||||
const ( |
||||
// QuotedPrintable represents the quoted-printable encoding as defined in
|
||||
// RFC 2045.
|
||||
QuotedPrintable Encoding = "quoted-printable" |
||||
// Base64 represents the base64 encoding as defined in RFC 2045.
|
||||
Base64 Encoding = "base64" |
||||
// Unencoded can be used to avoid encoding the body of an email. The headers
|
||||
// will still be encoded using quoted-printable encoding.
|
||||
Unencoded Encoding = "8bit" |
||||
) |
||||
|
||||
// SetHeader sets a value to the given header field.
|
||||
func (m *Message) SetHeader(field string, value ...string) { |
||||
m.encodeHeader(value) |
||||
m.header[field] = value |
||||
} |
||||
|
||||
func (m *Message) encodeHeader(values []string) { |
||||
for i := range values { |
||||
values[i] = m.encodeString(values[i]) |
||||
} |
||||
} |
||||
|
||||
func (m *Message) encodeString(value string) string { |
||||
return m.hEncoder.Encode(m.charset, value) |
||||
} |
||||
|
||||
// SetHeaders sets the message headers.
|
||||
func (m *Message) SetHeaders(h map[string][]string) { |
||||
for k, v := range h { |
||||
m.SetHeader(k, v...) |
||||
} |
||||
} |
||||
|
||||
// SetAddressHeader sets an address to the given header field.
|
||||
func (m *Message) SetAddressHeader(field, address, name string) { |
||||
m.header[field] = []string{m.FormatAddress(address, name)} |
||||
} |
||||
|
||||
// FormatAddress formats an address and a name as a valid RFC 5322 address.
|
||||
func (m *Message) FormatAddress(address, name string) string { |
||||
enc := m.encodeString(name) |
||||
if enc == name { |
||||
m.buf.WriteByte('"') |
||||
for i := 0; i < len(name); i++ { |
||||
b := name[i] |
||||
if b == '\\' || b == '"' { |
||||
m.buf.WriteByte('\\') |
||||
} |
||||
m.buf.WriteByte(b) |
||||
} |
||||
m.buf.WriteByte('"') |
||||
} else if hasSpecials(name) { |
||||
m.buf.WriteString(bEncoding.Encode(m.charset, name)) |
||||
} else { |
||||
m.buf.WriteString(enc) |
||||
} |
||||
m.buf.WriteString(" <") |
||||
m.buf.WriteString(address) |
||||
m.buf.WriteByte('>') |
||||
|
||||
addr := m.buf.String() |
||||
m.buf.Reset() |
||||
return addr |
||||
} |
||||
|
||||
func hasSpecials(text string) bool { |
||||
for i := 0; i < len(text); i++ { |
||||
switch c := text[i]; c { |
||||
case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"': |
||||
return true |
||||
} |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
// SetDateHeader sets a date to the given header field.
|
||||
func (m *Message) SetDateHeader(field string, date time.Time) { |
||||
m.header[field] = []string{m.FormatDate(date)} |
||||
} |
||||
|
||||
// FormatDate formats a date as a valid RFC 5322 date.
|
||||
func (m *Message) FormatDate(date time.Time) string { |
||||
return date.Format(time.RFC1123Z) |
||||
} |
||||
|
||||
// GetHeader gets a header field.
|
||||
func (m *Message) GetHeader(field string) []string { |
||||
return m.header[field] |
||||
} |
||||
|
||||
// SetBody sets the body of the message.
|
||||
func (m *Message) SetBody(contentType, body string) { |
||||
m.parts = []part{ |
||||
{ |
||||
header: m.getPartHeader(contentType), |
||||
copier: func(w io.Writer) error { |
||||
_, err := io.WriteString(w, body) |
||||
return err |
||||
}, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
// AddAlternative adds an alternative part to the message.
|
||||
//
|
||||
// It is commonly used to send HTML emails that default to the plain text
|
||||
// version for backward compatibility.
|
||||
//
|
||||
// More info: http://en.wikipedia.org/wiki/MIME#Alternative
|
||||
func (m *Message) AddAlternative(contentType, body string) { |
||||
m.parts = append(m.parts, |
||||
part{ |
||||
header: m.getPartHeader(contentType), |
||||
copier: func(w io.Writer) error { |
||||
_, err := io.WriteString(w, body) |
||||
return err |
||||
}, |
||||
}, |
||||
) |
||||
} |
||||
|
||||
// AddAlternativeWriter adds an alternative part to the message. It can be
|
||||
// useful with the text/template or html/template packages.
|
||||
func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error) { |
||||
m.parts = []part{ |
||||
{ |
||||
header: m.getPartHeader(contentType), |
||||
copier: f, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func (m *Message) getPartHeader(contentType string) header { |
||||
return map[string][]string{ |
||||
"Content-Type": {contentType + "; charset=" + m.charset}, |
||||
"Content-Transfer-Encoding": {string(m.encoding)}, |
||||
} |
||||
} |
||||
|
||||
type file struct { |
||||
Name string |
||||
Header map[string][]string |
||||
CopyFunc func(w io.Writer) error |
||||
} |
||||
|
||||
func (f *file) setHeader(field, value string) { |
||||
f.Header[field] = []string{value} |
||||
} |
||||
|
||||
// A FileSetting can be used as an argument in Message.Attach or Message.Embed.
|
||||
type FileSetting func(*file) |
||||
|
||||
// SetHeader is a file setting to set the MIME header of the message part that
|
||||
// contains the file content.
|
||||
//
|
||||
// Mandatory headers are automatically added if they are not set when sending
|
||||
// the email.
|
||||
func SetHeader(h map[string][]string) FileSetting { |
||||
return func(f *file) { |
||||
for k, v := range h { |
||||
f.Header[k] = v |
||||
} |
||||
} |
||||
} |
||||
|
||||
// SetCopyFunc is a file setting to replace the function that runs when the
|
||||
// message is sent. It should copy the content of the file to the io.Writer.
|
||||
//
|
||||
// The default copy function opens the file with the given filename, and copy
|
||||
// its content to the io.Writer.
|
||||
func SetCopyFunc(f func(io.Writer) error) FileSetting { |
||||
return func(fi *file) { |
||||
fi.CopyFunc = f |
||||
} |
||||
} |
||||
|
||||
func (m *Message) appendFile(list []*file, name string, settings []FileSetting) []*file { |
||||
f := &file{ |
||||
Name: filepath.Base(name), |
||||
Header: make(map[string][]string), |
||||
CopyFunc: func(w io.Writer) error { |
||||
h, err := os.Open(name) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if _, err := io.Copy(w, h); err != nil { |
||||
h.Close() |
||||
return err |
||||
} |
||||
return h.Close() |
||||
}, |
||||
} |
||||
|
||||
for _, s := range settings { |
||||
s(f) |
||||
} |
||||
|
||||
if list == nil { |
||||
return []*file{f} |
||||
} |
||||
|
||||
return append(list, f) |
||||
} |
||||
|
||||
// Attach attaches the files to the email.
|
||||
func (m *Message) Attach(filename string, settings ...FileSetting) { |
||||
m.attachments = m.appendFile(m.attachments, filename, settings) |
||||
} |
||||
|
||||
// Embed embeds the images to the email.
|
||||
func (m *Message) Embed(filename string, settings ...FileSetting) { |
||||
m.embedded = m.appendFile(m.embedded, filename, settings) |
||||
} |
||||
@ -0,0 +1,630 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"bytes" |
||||
"encoding/base64" |
||||
"io" |
||||
"io/ioutil" |
||||
"path/filepath" |
||||
"regexp" |
||||
"strconv" |
||||
"strings" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
func init() { |
||||
now = func() time.Time { |
||||
return time.Date(2014, 06, 25, 17, 46, 0, 0, time.UTC) |
||||
} |
||||
} |
||||
|
||||
type message struct { |
||||
from string |
||||
to []string |
||||
content string |
||||
} |
||||
|
||||
func TestMessage(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetAddressHeader("From", "from@example.com", "Señor From") |
||||
m.SetHeader("To", m.FormatAddress("to@example.com", "Señor To"), "tobis@example.com") |
||||
m.SetAddressHeader("Cc", "cc@example.com", "A, B") |
||||
m.SetAddressHeader("X-To", "ccbis@example.com", "à, b") |
||||
m.SetDateHeader("X-Date", now()) |
||||
m.SetHeader("X-Date-2", m.FormatDate(now())) |
||||
m.SetHeader("Subject", "¡Hola, señor!") |
||||
m.SetHeaders(map[string][]string{ |
||||
"X-Headers": {"Test", "Café"}, |
||||
}) |
||||
m.SetBody("text/plain", "¡Hola, señor!") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{ |
||||
"to@example.com", |
||||
"tobis@example.com", |
||||
"cc@example.com", |
||||
}, |
||||
content: "From: =?UTF-8?q?Se=C3=B1or_From?= <from@example.com>\r\n" + |
||||
"To: =?UTF-8?q?Se=C3=B1or_To?= <to@example.com>, tobis@example.com\r\n" + |
||||
"Cc: \"A, B\" <cc@example.com>\r\n" + |
||||
"X-To: =?UTF-8?b?w6AsIGI=?= <ccbis@example.com>\r\n" + |
||||
"X-Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + |
||||
"X-Date-2: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + |
||||
"X-Headers: Test, =?UTF-8?q?Caf=C3=A9?=\r\n" + |
||||
"Subject: =?UTF-8?q?=C2=A1Hola,_se=C3=B1or!?=\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"=C2=A1Hola, se=C3=B1or!", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestBodyWriter(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.AddAlternativeWriter("text/plain", func(w io.Writer) error { |
||||
_, err := w.Write([]byte("Test message")) |
||||
return err |
||||
}) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"Test message", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestCustomMessage(t *testing.T) { |
||||
m := NewMessage(SetCharset("ISO-8859-1"), SetEncoding(Base64)) |
||||
m.SetHeaders(map[string][]string{ |
||||
"From": {"from@example.com"}, |
||||
"To": {"to@example.com"}, |
||||
"Subject": {"Café"}, |
||||
}) |
||||
m.SetBody("text/html", "¡Hola, señor!") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Subject: =?ISO-8859-1?b?Q2Fmw6k=?=\r\n" + |
||||
"Content-Type: text/html; charset=ISO-8859-1\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
"wqFIb2xhLCBzZcOxb3Ih", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestUnencodedMessage(t *testing.T) { |
||||
m := NewMessage(SetEncoding(Unencoded)) |
||||
m.SetHeaders(map[string][]string{ |
||||
"From": {"from@example.com"}, |
||||
"To": {"to@example.com"}, |
||||
"Subject": {"Café"}, |
||||
}) |
||||
m.SetBody("text/html", "¡Hola, señor!") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Subject: =?UTF-8?q?Caf=C3=A9?=\r\n" + |
||||
"Content-Type: text/html; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: 8bit\r\n" + |
||||
"\r\n" + |
||||
"¡Hola, señor!", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestRecipients(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeaders(map[string][]string{ |
||||
"From": {"from@example.com"}, |
||||
"To": {"to@example.com"}, |
||||
"Cc": {"cc@example.com"}, |
||||
"Bcc": {"bcc1@example.com", "bcc2@example.com"}, |
||||
"Subject": {"Hello!"}, |
||||
}) |
||||
m.SetBody("text/plain", "Test message") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com", "cc@example.com", "bcc1@example.com", "bcc2@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Cc: cc@example.com\r\n" + |
||||
"Subject: Hello!\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"Test message", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestAlternative(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", "¡Hola, señor!") |
||||
m.AddAlternative("text/html", "¡<b>Hola</b>, <i>señor</i>!</h1>") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: multipart/alternative; boundary=_BOUNDARY_1_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"=C2=A1Hola, se=C3=B1or!\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: text/html; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"=C2=A1<b>Hola</b>, <i>se=C3=B1or</i>!</h1>\r\n" + |
||||
"--_BOUNDARY_1_--\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 1, want) |
||||
} |
||||
|
||||
func TestAttachmentOnly(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.Attach(mockCopyFile("/tmp/test.pdf")) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: application/pdf; name=\"test.pdf\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")), |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestAttachment(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", "Test") |
||||
m.Attach(mockCopyFile("/tmp/test.pdf")) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: multipart/mixed; boundary=_BOUNDARY_1_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"Test\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: application/pdf; name=\"test.pdf\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + |
||||
"--_BOUNDARY_1_--\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 1, want) |
||||
} |
||||
|
||||
func TestAttachmentsOnly(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.Attach(mockCopyFile("/tmp/test.pdf")) |
||||
m.Attach(mockCopyFile("/tmp/test.zip")) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: multipart/mixed; boundary=_BOUNDARY_1_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: application/pdf; name=\"test.pdf\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: application/zip; name=\"test.zip\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.zip\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" + |
||||
"--_BOUNDARY_1_--\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 1, want) |
||||
} |
||||
|
||||
func TestAttachments(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", "Test") |
||||
m.Attach(mockCopyFile("/tmp/test.pdf")) |
||||
m.Attach(mockCopyFile("/tmp/test.zip")) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: multipart/mixed; boundary=_BOUNDARY_1_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"Test\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: application/pdf; name=\"test.pdf\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: application/zip; name=\"test.zip\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.zip\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.zip")) + "\r\n" + |
||||
"--_BOUNDARY_1_--\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 1, want) |
||||
} |
||||
|
||||
func TestEmbedded(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.Embed(mockCopyFileWithHeader(m, "image1.jpg", map[string][]string{"Content-ID": {"<test-content-id>"}})) |
||||
m.Embed(mockCopyFile("image2.jpg")) |
||||
m.SetBody("text/plain", "Test") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: multipart/related; boundary=_BOUNDARY_1_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"Test\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: image/jpeg; name=\"image1.jpg\"\r\n" + |
||||
"Content-Disposition: inline; filename=\"image1.jpg\"\r\n" + |
||||
"Content-ID: <test-content-id>\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of image1.jpg")) + "\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: image/jpeg; name=\"image2.jpg\"\r\n" + |
||||
"Content-Disposition: inline; filename=\"image2.jpg\"\r\n" + |
||||
"Content-ID: <image2.jpg>\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of image2.jpg")) + "\r\n" + |
||||
"--_BOUNDARY_1_--\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 1, want) |
||||
} |
||||
|
||||
func TestFullMessage(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", "¡Hola, señor!") |
||||
m.AddAlternative("text/html", "¡<b>Hola</b>, <i>señor</i>!</h1>") |
||||
m.Attach(mockCopyFile("test.pdf")) |
||||
m.Embed(mockCopyFile("image.jpg")) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: multipart/mixed; boundary=_BOUNDARY_1_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: multipart/related; boundary=_BOUNDARY_2_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_2_\r\n" + |
||||
"Content-Type: multipart/alternative; boundary=_BOUNDARY_3_\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_3_\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"=C2=A1Hola, se=C3=B1or!\r\n" + |
||||
"--_BOUNDARY_3_\r\n" + |
||||
"Content-Type: text/html; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"=C2=A1<b>Hola</b>, <i>se=C3=B1or</i>!</h1>\r\n" + |
||||
"--_BOUNDARY_3_--\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_2_\r\n" + |
||||
"Content-Type: image/jpeg; name=\"image.jpg\"\r\n" + |
||||
"Content-Disposition: inline; filename=\"image.jpg\"\r\n" + |
||||
"Content-ID: <image.jpg>\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of image.jpg")) + "\r\n" + |
||||
"--_BOUNDARY_2_--\r\n" + |
||||
"\r\n" + |
||||
"--_BOUNDARY_1_\r\n" + |
||||
"Content-Type: application/pdf; name=\"test.pdf\"\r\n" + |
||||
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
base64.StdEncoding.EncodeToString([]byte("Content of test.pdf")) + "\r\n" + |
||||
"--_BOUNDARY_1_--\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 3, want) |
||||
|
||||
want = &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
"Test reset", |
||||
} |
||||
m.Reset() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", "Test reset") |
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestQpLineLength(t *testing.T) { |
||||
m := NewMessage() |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", |
||||
strings.Repeat("0", 76)+"\r\n"+ |
||||
strings.Repeat("0", 75)+"à\r\n"+ |
||||
strings.Repeat("0", 74)+"à\r\n"+ |
||||
strings.Repeat("0", 73)+"à\r\n"+ |
||||
strings.Repeat("0", 72)+"à\r\n"+ |
||||
strings.Repeat("0", 75)+"\r\n"+ |
||||
strings.Repeat("0", 76)+"\n") |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
strings.Repeat("0", 75) + "=\r\n0\r\n" + |
||||
strings.Repeat("0", 75) + "=\r\n=C3=A0\r\n" + |
||||
strings.Repeat("0", 74) + "=\r\n=C3=A0\r\n" + |
||||
strings.Repeat("0", 73) + "=\r\n=C3=A0\r\n" + |
||||
strings.Repeat("0", 72) + "=C3=\r\n=A0\r\n" + |
||||
strings.Repeat("0", 75) + "\r\n" + |
||||
strings.Repeat("0", 75) + "=\r\n0\r\n", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func TestBase64LineLength(t *testing.T) { |
||||
m := NewMessage(SetCharset("UTF-8"), SetEncoding(Base64)) |
||||
m.SetHeader("From", "from@example.com") |
||||
m.SetHeader("To", "to@example.com") |
||||
m.SetBody("text/plain", strings.Repeat("0", 58)) |
||||
|
||||
want := &message{ |
||||
from: "from@example.com", |
||||
to: []string{"to@example.com"}, |
||||
content: "From: from@example.com\r\n" + |
||||
"To: to@example.com\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: base64\r\n" + |
||||
"\r\n" + |
||||
strings.Repeat("MDAw", 19) + "\r\nMA==", |
||||
} |
||||
|
||||
testMessage(t, m, 0, want) |
||||
} |
||||
|
||||
func testMessage(t *testing.T, m *Message, bCount int, want *message) { |
||||
err := Send(stubSendMail(t, bCount, want), m) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
} |
||||
|
||||
func stubSendMail(t *testing.T, bCount int, want *message) SendFunc { |
||||
return func(from string, to []string, m io.WriterTo) error { |
||||
if from != want.from { |
||||
t.Fatalf("Invalid from, got %q, want %q", from, want.from) |
||||
} |
||||
|
||||
if len(to) != len(want.to) { |
||||
t.Fatalf("Invalid recipient count, \ngot %d: %q\nwant %d: %q", |
||||
len(to), to, |
||||
len(want.to), want.to, |
||||
) |
||||
} |
||||
for i := range want.to { |
||||
if to[i] != want.to[i] { |
||||
t.Fatalf("Invalid recipient, got %q, want %q", |
||||
to[i], want.to[i], |
||||
) |
||||
} |
||||
} |
||||
|
||||
buf := new(bytes.Buffer) |
||||
_, err := m.WriteTo(buf) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
got := buf.String() |
||||
wantMsg := string("Mime-Version: 1.0\r\n" + |
||||
"Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + |
||||
want.content) |
||||
if bCount > 0 { |
||||
boundaries := getBoundaries(t, bCount, got) |
||||
for i, b := range boundaries { |
||||
wantMsg = strings.Replace(wantMsg, "_BOUNDARY_"+strconv.Itoa(i+1)+"_", b, -1) |
||||
} |
||||
} |
||||
|
||||
compareBodies(t, got, wantMsg) |
||||
|
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func compareBodies(t *testing.T, got, want string) { |
||||
// We cannot do a simple comparison since the ordering of headers' fields
|
||||
// is random.
|
||||
gotLines := strings.Split(got, "\r\n") |
||||
wantLines := strings.Split(want, "\r\n") |
||||
|
||||
// We only test for too many lines, missing lines are tested after
|
||||
if len(gotLines) > len(wantLines) { |
||||
t.Fatalf("Message has too many lines, \ngot %d:\n%s\nwant %d:\n%s", len(gotLines), got, len(wantLines), want) |
||||
} |
||||
|
||||
isInHeader := true |
||||
headerStart := 0 |
||||
for i, line := range wantLines { |
||||
if line == gotLines[i] { |
||||
if line == "" { |
||||
isInHeader = false |
||||
} else if !isInHeader && len(line) > 2 && line[:2] == "--" { |
||||
isInHeader = true |
||||
headerStart = i + 1 |
||||
} |
||||
continue |
||||
} |
||||
|
||||
if !isInHeader { |
||||
missingLine(t, line, got, want) |
||||
} |
||||
|
||||
isMissing := true |
||||
for j := headerStart; j < len(gotLines); j++ { |
||||
if gotLines[j] == "" { |
||||
break |
||||
} |
||||
if gotLines[j] == line { |
||||
isMissing = false |
||||
break |
||||
} |
||||
} |
||||
if isMissing { |
||||
missingLine(t, line, got, want) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func missingLine(t *testing.T, line, got, want string) { |
||||
t.Fatalf("Missing line %q\ngot:\n%s\nwant:\n%s", line, got, want) |
||||
} |
||||
|
||||
func getBoundaries(t *testing.T, count int, m string) []string { |
||||
if matches := boundaryRegExp.FindAllStringSubmatch(m, count); matches != nil { |
||||
boundaries := make([]string, count) |
||||
for i, match := range matches { |
||||
boundaries[i] = match[1] |
||||
} |
||||
return boundaries |
||||
} |
||||
|
||||
t.Fatal("Boundary not found in body") |
||||
return []string{""} |
||||
} |
||||
|
||||
var boundaryRegExp = regexp.MustCompile("boundary=(\\w+)") |
||||
|
||||
func mockCopyFile(name string) (string, FileSetting) { |
||||
return name, SetCopyFunc(func(w io.Writer) error { |
||||
_, err := w.Write([]byte("Content of " + filepath.Base(name))) |
||||
return err |
||||
}) |
||||
} |
||||
|
||||
func mockCopyFileWithHeader(m *Message, name string, h map[string][]string) (string, FileSetting, FileSetting) { |
||||
name, f := mockCopyFile(name) |
||||
return name, f, SetHeader(h) |
||||
} |
||||
|
||||
func BenchmarkFull(b *testing.B) { |
||||
discardFunc := SendFunc(func(from string, to []string, m io.WriterTo) error { |
||||
_, err := m.WriteTo(ioutil.Discard) |
||||
return err |
||||
}) |
||||
|
||||
m := NewMessage() |
||||
b.ResetTimer() |
||||
for n := 0; n < b.N; n++ { |
||||
m.SetAddressHeader("From", "from@example.com", "Señor From") |
||||
m.SetHeaders(map[string][]string{ |
||||
"To": {"to@example.com"}, |
||||
"Cc": {"cc@example.com"}, |
||||
"Bcc": {"bcc1@example.com", "bcc2@example.com"}, |
||||
"Subject": {"¡Hola, señor!"}, |
||||
}) |
||||
m.SetBody("text/plain", "¡Hola, señor!") |
||||
m.AddAlternative("text/html", "<p>¡Hola, señor!</p>") |
||||
m.Attach(mockCopyFile("benchmark.txt")) |
||||
m.Embed(mockCopyFile("benchmark.jpg")) |
||||
|
||||
if err := Send(discardFunc, m); err != nil { |
||||
panic(err) |
||||
} |
||||
m.Reset() |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@
|
||||
// +build go1.5
|
||||
|
||||
package gomail |
||||
|
||||
import ( |
||||
"mime" |
||||
"mime/quotedprintable" |
||||
) |
||||
|
||||
var newQPWriter = quotedprintable.NewWriter |
||||
|
||||
type mimeEncoder struct { |
||||
mime.WordEncoder |
||||
} |
||||
|
||||
var ( |
||||
bEncoding = mimeEncoder{mime.BEncoding} |
||||
qEncoding = mimeEncoder{mime.QEncoding} |
||||
) |
||||
@ -0,0 +1,16 @@
|
||||
// +build !go1.5
|
||||
|
||||
package gomail |
||||
|
||||
import "gopkg.in/alexcesaro/quotedprintable.v3" |
||||
|
||||
var newQPWriter = quotedprintable.NewWriter |
||||
|
||||
type mimeEncoder struct { |
||||
quotedprintable.WordEncoder |
||||
} |
||||
|
||||
var ( |
||||
bEncoding = mimeEncoder{quotedprintable.BEncoding} |
||||
qEncoding = mimeEncoder{quotedprintable.QEncoding} |
||||
) |
||||
@ -0,0 +1,117 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net/mail" |
||||
) |
||||
|
||||
// Sender is the interface that wraps the Send method.
|
||||
//
|
||||
// Send sends an email to the given addresses.
|
||||
type Sender interface { |
||||
Send(from string, to []string, msg io.WriterTo) error |
||||
} |
||||
|
||||
// SendCloser is the interface that groups the Send and Close methods.
|
||||
type SendCloser interface { |
||||
Sender |
||||
Close() error |
||||
} |
||||
|
||||
// A SendFunc is a function that sends emails to the given adresses.
|
||||
//
|
||||
// The SendFunc type is an adapter to allow the use of ordinary functions as
|
||||
// email senders. If f is a function with the appropriate signature, SendFunc(f)
|
||||
// is a Sender object that calls f.
|
||||
type SendFunc func(from string, to []string, msg io.WriterTo) error |
||||
|
||||
// Send calls f(from, to, msg).
|
||||
func (f SendFunc) Send(from string, to []string, msg io.WriterTo) error { |
||||
return f(from, to, msg) |
||||
} |
||||
|
||||
// Send sends emails using the given Sender.
|
||||
func Send(s Sender, msg ...*Message) error { |
||||
for i, m := range msg { |
||||
if err := send(s, m); err != nil { |
||||
return fmt.Errorf("gomail: could not send email %d: %v", i+1, err) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func send(s Sender, m *Message) error { |
||||
from, err := m.getFrom() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
to, err := m.getRecipients() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err := s.Send(from, to, m); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (m *Message) getFrom() (string, error) { |
||||
from := m.header["Sender"] |
||||
if len(from) == 0 { |
||||
from = m.header["From"] |
||||
if len(from) == 0 { |
||||
return "", errors.New(`gomail: invalid message, "From" field is absent`) |
||||
} |
||||
} |
||||
|
||||
return parseAddress(from[0]) |
||||
} |
||||
|
||||
func (m *Message) getRecipients() ([]string, error) { |
||||
n := 0 |
||||
for _, field := range []string{"To", "Cc", "Bcc"} { |
||||
if addresses, ok := m.header[field]; ok { |
||||
n += len(addresses) |
||||
} |
||||
} |
||||
list := make([]string, 0, n) |
||||
|
||||
for _, field := range []string{"To", "Cc", "Bcc"} { |
||||
if addresses, ok := m.header[field]; ok { |
||||
for _, a := range addresses { |
||||
addr, err := parseAddress(a) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
list = addAddress(list, addr) |
||||
} |
||||
} |
||||
} |
||||
|
||||
return list, nil |
||||
} |
||||
|
||||
func addAddress(list []string, addr string) []string { |
||||
for _, a := range list { |
||||
if addr == a { |
||||
return list |
||||
} |
||||
} |
||||
|
||||
return append(list, addr) |
||||
} |
||||
|
||||
func parseAddress(field string) (string, error) { |
||||
a, err := mail.ParseAddress(field) |
||||
if a == nil { |
||||
return "", err |
||||
} |
||||
|
||||
return a.Address, err |
||||
} |
||||
@ -0,0 +1,80 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"bytes" |
||||
"io" |
||||
"reflect" |
||||
"testing" |
||||
) |
||||
|
||||
const ( |
||||
testTo1 = "to1@example.com" |
||||
testTo2 = "to2@example.com" |
||||
testFrom = "from@example.com" |
||||
testBody = "Test message" |
||||
testMsg = "To: " + testTo1 + ", " + testTo2 + "\r\n" + |
||||
"From: " + testFrom + "\r\n" + |
||||
"Mime-Version: 1.0\r\n" + |
||||
"Date: Wed, 25 Jun 2014 17:46:00 +0000\r\n" + |
||||
"Content-Type: text/plain; charset=UTF-8\r\n" + |
||||
"Content-Transfer-Encoding: quoted-printable\r\n" + |
||||
"\r\n" + |
||||
testBody |
||||
) |
||||
|
||||
type mockSender SendFunc |
||||
|
||||
func (s mockSender) Send(from string, to []string, msg io.WriterTo) error { |
||||
return s(from, to, msg) |
||||
} |
||||
|
||||
type mockSendCloser struct { |
||||
mockSender |
||||
close func() error |
||||
} |
||||
|
||||
func (s *mockSendCloser) Close() error { |
||||
return s.close() |
||||
} |
||||
|
||||
func TestSend(t *testing.T) { |
||||
s := &mockSendCloser{ |
||||
mockSender: stubSend(t, testFrom, []string{testTo1, testTo2}, testMsg), |
||||
close: func() error { |
||||
t.Error("Close() should not be called in Send()") |
||||
return nil |
||||
}, |
||||
} |
||||
if err := Send(s, getTestMessage()); err != nil { |
||||
t.Errorf("Send(): %v", err) |
||||
} |
||||
} |
||||
|
||||
func getTestMessage() *Message { |
||||
m := NewMessage() |
||||
m.SetHeader("From", testFrom) |
||||
m.SetHeader("To", testTo1, testTo2) |
||||
m.SetBody("text/plain", testBody) |
||||
|
||||
return m |
||||
} |
||||
|
||||
func stubSend(t *testing.T, wantFrom string, wantTo []string, wantBody string) mockSender { |
||||
return func(from string, to []string, msg io.WriterTo) error { |
||||
if from != wantFrom { |
||||
t.Errorf("invalid from, got %q, want %q", from, wantFrom) |
||||
} |
||||
if !reflect.DeepEqual(to, wantTo) { |
||||
t.Errorf("invalid to, got %v, want %v", to, wantTo) |
||||
} |
||||
|
||||
buf := new(bytes.Buffer) |
||||
_, err := msg.WriteTo(buf) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
compareBodies(t, buf.String(), wantBody) |
||||
|
||||
return nil |
||||
} |
||||
} |
||||
@ -0,0 +1,175 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"crypto/tls" |
||||
"fmt" |
||||
"io" |
||||
"net" |
||||
"net/smtp" |
||||
) |
||||
|
||||
// A Dialer is a dialer to an SMTP server.
|
||||
type Dialer struct { |
||||
// Host represents the host of the SMTP server.
|
||||
Host string |
||||
// Port represents the port of the SMTP server.
|
||||
Port int |
||||
// Auth represents the authentication mechanism used to authenticate to the
|
||||
// SMTP server.
|
||||
Auth smtp.Auth |
||||
// SSL defines whether an SSL connection is used. It should be false in
|
||||
// most cases since the authentication mechanism should use the STARTTLS
|
||||
// extension instead.
|
||||
SSL bool |
||||
// TSLConfig represents the TLS configuration used for the TLS (when the
|
||||
// STARTTLS extension is used) or SSL connection.
|
||||
TLSConfig *tls.Config |
||||
} |
||||
|
||||
// NewPlainDialer returns a Dialer. The given parameters are used to connect to
|
||||
// the SMTP server via a PLAIN authentication mechanism.
|
||||
//
|
||||
// It fallbacks to the LOGIN mechanism if it is the only mechanism advertised by
|
||||
// the server.
|
||||
func NewPlainDialer(host string, port int, username, password string) *Dialer { |
||||
return &Dialer{ |
||||
Host: host, |
||||
Port: port, |
||||
Auth: &plainAuth{ |
||||
username: username, |
||||
password: password, |
||||
host: host, |
||||
}, |
||||
SSL: port == 465, |
||||
} |
||||
} |
||||
|
||||
// Dial dials and authenticates to an SMTP server. The returned SendCloser
|
||||
// should be closed when done using it.
|
||||
func (d *Dialer) Dial() (SendCloser, error) { |
||||
c, err := d.dial() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if d.Auth != nil { |
||||
if ok, _ := c.Extension("AUTH"); ok { |
||||
if err = c.Auth(d.Auth); err != nil { |
||||
c.Close() |
||||
return nil, err |
||||
} |
||||
} |
||||
} |
||||
|
||||
return &smtpSender{c}, nil |
||||
} |
||||
|
||||
func (d *Dialer) dial() (smtpClient, error) { |
||||
if d.SSL { |
||||
return d.sslDial() |
||||
} |
||||
return d.starttlsDial() |
||||
} |
||||
|
||||
func (d *Dialer) starttlsDial() (smtpClient, error) { |
||||
c, err := smtpDial(addr(d.Host, d.Port)) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if ok, _ := c.Extension("STARTTLS"); ok { |
||||
if err := c.StartTLS(d.tlsConfig()); err != nil { |
||||
c.Close() |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
return c, nil |
||||
} |
||||
|
||||
func (d *Dialer) sslDial() (smtpClient, error) { |
||||
conn, err := tlsDial("tcp", addr(d.Host, d.Port), d.tlsConfig()) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return newClient(conn, d.Host) |
||||
} |
||||
|
||||
func (d *Dialer) tlsConfig() *tls.Config { |
||||
if d.TLSConfig == nil { |
||||
return &tls.Config{ServerName: d.Host} |
||||
} |
||||
|
||||
return d.TLSConfig |
||||
} |
||||
|
||||
func addr(host string, port int) string { |
||||
return fmt.Sprintf("%s:%d", host, port) |
||||
} |
||||
|
||||
// DialAndSend opens a connection to the SMTP server, sends the given emails and
|
||||
// closes the connection.
|
||||
func (d *Dialer) DialAndSend(m ...*Message) error { |
||||
s, err := d.Dial() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer s.Close() |
||||
|
||||
return Send(s, m...) |
||||
} |
||||
|
||||
type smtpSender struct { |
||||
smtpClient |
||||
} |
||||
|
||||
func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error { |
||||
if err := c.Mail(from); err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, addr := range to { |
||||
if err := c.Rcpt(addr); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
w, err := c.Data() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if _, err = msg.WriteTo(w); err != nil { |
||||
w.Close() |
||||
return err |
||||
} |
||||
|
||||
return w.Close() |
||||
} |
||||
|
||||
func (c *smtpSender) Close() error { |
||||
return c.Quit() |
||||
} |
||||
|
||||
// Stubbed out for tests.
|
||||
var ( |
||||
smtpDial = func(addr string) (smtpClient, error) { |
||||
return smtp.Dial(addr) |
||||
} |
||||
tlsDial = tls.Dial |
||||
newClient = func(conn net.Conn, host string) (smtpClient, error) { |
||||
return smtp.NewClient(conn, host) |
||||
} |
||||
) |
||||
|
||||
type smtpClient interface { |
||||
Extension(string) (bool, string) |
||||
StartTLS(*tls.Config) error |
||||
Auth(smtp.Auth) error |
||||
Mail(string) error |
||||
Rcpt(string) error |
||||
Data() (io.WriteCloser, error) |
||||
Quit() error |
||||
Close() error |
||||
} |
||||
@ -0,0 +1,254 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"bytes" |
||||
"crypto/tls" |
||||
"io" |
||||
"net" |
||||
"net/smtp" |
||||
"reflect" |
||||
"testing" |
||||
) |
||||
|
||||
const ( |
||||
testPort = 587 |
||||
testSSLPort = 465 |
||||
) |
||||
|
||||
var ( |
||||
testTLSConn = &tls.Conn{} |
||||
testConfig = &tls.Config{InsecureSkipVerify: true} |
||||
) |
||||
|
||||
func TestDialer(t *testing.T) { |
||||
d := NewPlainDialer(testHost, testPort, "user", "pwd") |
||||
testSendMail(t, d, []string{ |
||||
"Extension STARTTLS", |
||||
"StartTLS", |
||||
"Extension AUTH", |
||||
"Auth", |
||||
"Mail " + testFrom, |
||||
"Rcpt " + testTo1, |
||||
"Rcpt " + testTo2, |
||||
"Data", |
||||
"Write message", |
||||
"Close writer", |
||||
"Quit", |
||||
"Close", |
||||
}) |
||||
} |
||||
|
||||
func TestDialerSSL(t *testing.T) { |
||||
d := NewPlainDialer(testHost, testSSLPort, "user", "pwd") |
||||
testSendMail(t, d, []string{ |
||||
"Extension AUTH", |
||||
"Auth", |
||||
"Mail " + testFrom, |
||||
"Rcpt " + testTo1, |
||||
"Rcpt " + testTo2, |
||||
"Data", |
||||
"Write message", |
||||
"Close writer", |
||||
"Quit", |
||||
"Close", |
||||
}) |
||||
} |
||||
|
||||
func TestDialerConfig(t *testing.T) { |
||||
d := NewPlainDialer(testHost, testPort, "user", "pwd") |
||||
d.TLSConfig = testConfig |
||||
testSendMail(t, d, []string{ |
||||
"Extension STARTTLS", |
||||
"StartTLS", |
||||
"Extension AUTH", |
||||
"Auth", |
||||
"Mail " + testFrom, |
||||
"Rcpt " + testTo1, |
||||
"Rcpt " + testTo2, |
||||
"Data", |
||||
"Write message", |
||||
"Close writer", |
||||
"Quit", |
||||
"Close", |
||||
}) |
||||
} |
||||
|
||||
func TestDialerSSLConfig(t *testing.T) { |
||||
d := NewPlainDialer(testHost, testSSLPort, "user", "pwd") |
||||
d.TLSConfig = testConfig |
||||
testSendMail(t, d, []string{ |
||||
"Extension AUTH", |
||||
"Auth", |
||||
"Mail " + testFrom, |
||||
"Rcpt " + testTo1, |
||||
"Rcpt " + testTo2, |
||||
"Data", |
||||
"Write message", |
||||
"Close writer", |
||||
"Quit", |
||||
"Close", |
||||
}) |
||||
} |
||||
|
||||
func TestDialerNoAuth(t *testing.T) { |
||||
d := &Dialer{ |
||||
Host: testHost, |
||||
Port: testPort, |
||||
} |
||||
testSendMail(t, d, []string{ |
||||
"Extension STARTTLS", |
||||
"StartTLS", |
||||
"Mail " + testFrom, |
||||
"Rcpt " + testTo1, |
||||
"Rcpt " + testTo2, |
||||
"Data", |
||||
"Write message", |
||||
"Close writer", |
||||
"Quit", |
||||
"Close", |
||||
}) |
||||
} |
||||
|
||||
type mockClient struct { |
||||
t *testing.T |
||||
i int |
||||
want []string |
||||
addr string |
||||
auth smtp.Auth |
||||
config *tls.Config |
||||
} |
||||
|
||||
func (c *mockClient) Extension(ext string) (bool, string) { |
||||
c.do("Extension " + ext) |
||||
return true, "" |
||||
} |
||||
|
||||
func (c *mockClient) StartTLS(config *tls.Config) error { |
||||
assertConfig(c.t, config, c.config) |
||||
c.do("StartTLS") |
||||
return nil |
||||
} |
||||
|
||||
func (c *mockClient) Auth(a smtp.Auth) error { |
||||
assertAuth(c.t, a, c.auth) |
||||
c.do("Auth") |
||||
return nil |
||||
} |
||||
|
||||
func (c *mockClient) Mail(from string) error { |
||||
c.do("Mail " + from) |
||||
return nil |
||||
} |
||||
|
||||
func (c *mockClient) Rcpt(to string) error { |
||||
c.do("Rcpt " + to) |
||||
return nil |
||||
} |
||||
|
||||
func (c *mockClient) Data() (io.WriteCloser, error) { |
||||
c.do("Data") |
||||
return &mockWriter{c: c, want: testMsg}, nil |
||||
} |
||||
|
||||
func (c *mockClient) Quit() error { |
||||
c.do("Quit") |
||||
return nil |
||||
} |
||||
|
||||
func (c *mockClient) Close() error { |
||||
c.do("Close") |
||||
return nil |
||||
} |
||||
|
||||
func (c *mockClient) do(cmd string) { |
||||
if c.i >= len(c.want) { |
||||
c.t.Fatalf("Invalid command %q", cmd) |
||||
} |
||||
|
||||
if cmd != c.want[c.i] { |
||||
c.t.Fatalf("Invalid command, got %q, want %q", cmd, c.want[c.i]) |
||||
} |
||||
c.i++ |
||||
} |
||||
|
||||
type mockWriter struct { |
||||
want string |
||||
c *mockClient |
||||
buf bytes.Buffer |
||||
} |
||||
|
||||
func (w *mockWriter) Write(p []byte) (int, error) { |
||||
if w.buf.Len() == 0 { |
||||
w.c.do("Write message") |
||||
} |
||||
w.buf.Write(p) |
||||
return len(p), nil |
||||
} |
||||
|
||||
func (w *mockWriter) Close() error { |
||||
compareBodies(w.c.t, w.buf.String(), w.want) |
||||
w.c.do("Close writer") |
||||
return nil |
||||
} |
||||
|
||||
func testSendMail(t *testing.T, d *Dialer, want []string) { |
||||
testClient := &mockClient{ |
||||
t: t, |
||||
want: want, |
||||
addr: addr(d.Host, d.Port), |
||||
auth: testAuth, |
||||
config: d.TLSConfig, |
||||
} |
||||
|
||||
smtpDial = func(addr string) (smtpClient, error) { |
||||
assertAddr(t, addr, testClient.addr) |
||||
return testClient, nil |
||||
} |
||||
|
||||
tlsDial = func(network, addr string, config *tls.Config) (*tls.Conn, error) { |
||||
if network != "tcp" { |
||||
t.Errorf("Invalid network, got %q, want tcp", network) |
||||
} |
||||
assertAddr(t, addr, testClient.addr) |
||||
assertConfig(t, config, testClient.config) |
||||
return testTLSConn, nil |
||||
} |
||||
|
||||
newClient = func(conn net.Conn, host string) (smtpClient, error) { |
||||
if conn != testTLSConn { |
||||
t.Error("Invalid TLS connection used") |
||||
} |
||||
if host != testHost { |
||||
t.Errorf("Invalid host, got %q, want %q", host, testHost) |
||||
} |
||||
return testClient, nil |
||||
} |
||||
|
||||
if err := d.DialAndSend(getTestMessage()); err != nil { |
||||
t.Error(err) |
||||
} |
||||
} |
||||
|
||||
func assertAuth(t *testing.T, got, want smtp.Auth) { |
||||
if !reflect.DeepEqual(got, want) { |
||||
t.Errorf("Invalid auth, got %#v, want %#v", got, want) |
||||
} |
||||
} |
||||
|
||||
func assertAddr(t *testing.T, got, want string) { |
||||
if got != want { |
||||
t.Errorf("Invalid addr, got %q, want %q", got, want) |
||||
} |
||||
} |
||||
|
||||
func assertConfig(t *testing.T, got, want *tls.Config) { |
||||
if want == nil { |
||||
want = &tls.Config{ServerName: testHost} |
||||
} |
||||
if got.ServerName != want.ServerName { |
||||
t.Errorf("Invalid field ServerName in config, got %q, want %q", got.ServerName, want.ServerName) |
||||
} |
||||
if got.InsecureSkipVerify != want.InsecureSkipVerify { |
||||
t.Errorf("Invalid field InsecureSkipVerify in config, got %v, want %v", got.InsecureSkipVerify, want.InsecureSkipVerify) |
||||
} |
||||
} |
||||
@ -0,0 +1,242 @@
|
||||
package gomail |
||||
|
||||
import ( |
||||
"encoding/base64" |
||||
"errors" |
||||
"io" |
||||
"mime" |
||||
"mime/multipart" |
||||
"path/filepath" |
||||
"time" |
||||
) |
||||
|
||||
// WriteTo implements io.WriterTo. It dumps the whole message into w.
|
||||
func (m *Message) WriteTo(w io.Writer) (int64, error) { |
||||
mw := &messageWriter{w: w} |
||||
mw.writeMessage(m) |
||||
return mw.n, mw.err |
||||
} |
||||
|
||||
func (w *messageWriter) writeMessage(m *Message) { |
||||
if _, ok := m.header["Mime-Version"]; !ok { |
||||
w.writeString("Mime-Version: 1.0\r\n") |
||||
} |
||||
if _, ok := m.header["Date"]; !ok { |
||||
w.writeHeader("Date", m.FormatDate(now())) |
||||
} |
||||
w.writeHeaders(m.header) |
||||
|
||||
if m.hasMixedPart() { |
||||
w.openMultipart("mixed") |
||||
} |
||||
|
||||
if m.hasRelatedPart() { |
||||
w.openMultipart("related") |
||||
} |
||||
|
||||
if m.hasAlternativePart() { |
||||
w.openMultipart("alternative") |
||||
} |
||||
for _, part := range m.parts { |
||||
w.writeHeaders(part.header) |
||||
w.writeBody(part.copier, m.encoding) |
||||
} |
||||
if m.hasAlternativePart() { |
||||
w.closeMultipart() |
||||
} |
||||
|
||||
w.addFiles(m.embedded, false) |
||||
if m.hasRelatedPart() { |
||||
w.closeMultipart() |
||||
} |
||||
|
||||
w.addFiles(m.attachments, true) |
||||
if m.hasMixedPart() { |
||||
w.closeMultipart() |
||||
} |
||||
} |
||||
|
||||
func (m *Message) hasMixedPart() bool { |
||||
return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1 |
||||
} |
||||
|
||||
func (m *Message) hasRelatedPart() bool { |
||||
return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1 |
||||
} |
||||
|
||||
func (m *Message) hasAlternativePart() bool { |
||||
return len(m.parts) > 1 |
||||
} |
||||
|
||||
type messageWriter struct { |
||||
w io.Writer |
||||
n int64 |
||||
writers [3]*multipart.Writer |
||||
partWriter io.Writer |
||||
depth uint8 |
||||
err error |
||||
} |
||||
|
||||
func (w *messageWriter) openMultipart(mimeType string) { |
||||
mw := multipart.NewWriter(w) |
||||
contentType := "multipart/" + mimeType + "; boundary=" + mw.Boundary() |
||||
w.writers[w.depth] = mw |
||||
|
||||
if w.depth == 0 { |
||||
w.writeHeader("Content-Type", contentType) |
||||
w.writeString("\r\n") |
||||
} else { |
||||
w.createPart(map[string][]string{ |
||||
"Content-Type": {contentType}, |
||||
}) |
||||
} |
||||
w.depth++ |
||||
} |
||||
|
||||
func (w *messageWriter) createPart(h map[string][]string) { |
||||
w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h) |
||||
} |
||||
|
||||
func (w *messageWriter) closeMultipart() { |
||||
if w.depth > 0 { |
||||
w.writers[w.depth-1].Close() |
||||
w.depth-- |
||||
} |
||||
} |
||||
|
||||
func (w *messageWriter) addFiles(files []*file, isAttachment bool) { |
||||
for _, f := range files { |
||||
if _, ok := f.Header["Content-Type"]; !ok { |
||||
mediaType := mime.TypeByExtension(filepath.Ext(f.Name)) |
||||
if mediaType == "" { |
||||
mediaType = "application/octet-stream" |
||||
} |
||||
f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`) |
||||
} |
||||
|
||||
if _, ok := f.Header["Content-Transfer-Encoding"]; !ok { |
||||
f.setHeader("Content-Transfer-Encoding", string(Base64)) |
||||
} |
||||
|
||||
if _, ok := f.Header["Content-Disposition"]; !ok { |
||||
var disp string |
||||
if isAttachment { |
||||
disp = "attachment" |
||||
} else { |
||||
disp = "inline" |
||||
} |
||||
f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`) |
||||
} |
||||
|
||||
if !isAttachment { |
||||
if _, ok := f.Header["Content-ID"]; !ok { |
||||
f.setHeader("Content-ID", "<"+f.Name+">") |
||||
} |
||||
} |
||||
w.writeHeaders(f.Header) |
||||
w.writeBody(f.CopyFunc, Base64) |
||||
} |
||||
} |
||||
|
||||
func (w *messageWriter) Write(p []byte) (int, error) { |
||||
if w.err != nil { |
||||
return 0, errors.New("gomail: cannot write as writer is in error") |
||||
} |
||||
|
||||
var n int |
||||
n, w.err = w.w.Write(p) |
||||
w.n += int64(n) |
||||
return n, w.err |
||||
} |
||||
|
||||
func (w *messageWriter) writeString(s string) { |
||||
n, _ := io.WriteString(w.w, s) |
||||
w.n += int64(n) |
||||
} |
||||
|
||||
func (w *messageWriter) writeStrings(a []string, sep string) { |
||||
if len(a) > 0 { |
||||
w.writeString(a[0]) |
||||
if len(a) == 1 { |
||||
return |
||||
} |
||||
} |
||||
for _, s := range a[1:] { |
||||
w.writeString(sep) |
||||
w.writeString(s) |
||||
} |
||||
} |
||||
|
||||
func (w *messageWriter) writeHeader(k string, v ...string) { |
||||
w.writeString(k) |
||||
w.writeString(": ") |
||||
w.writeStrings(v, ", ") |
||||
w.writeString("\r\n") |
||||
} |
||||
|
||||
func (w *messageWriter) writeHeaders(h map[string][]string) { |
||||
if w.depth == 0 { |
||||
for k, v := range h { |
||||
if k != "Bcc" { |
||||
w.writeHeader(k, v...) |
||||
} |
||||
} |
||||
} else { |
||||
w.createPart(h) |
||||
} |
||||
} |
||||
|
||||
func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) { |
||||
var subWriter io.Writer |
||||
if w.depth == 0 { |
||||
w.writeString("\r\n") |
||||
subWriter = w.w |
||||
} else { |
||||
subWriter = w.partWriter |
||||
} |
||||
|
||||
if enc == Base64 { |
||||
wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) |
||||
w.err = f(wc) |
||||
wc.Close() |
||||
} else if enc == Unencoded { |
||||
w.err = f(subWriter) |
||||
} else { |
||||
wc := newQPWriter(subWriter) |
||||
w.err = f(wc) |
||||
wc.Close() |
||||
} |
||||
} |
||||
|
||||
// As required by RFC 2045, 6.7. (page 21) for quoted-printable, and
|
||||
// RFC 2045, 6.8. (page 25) for base64.
|
||||
const maxLineLen = 76 |
||||
|
||||
// base64LineWriter limits text encoded in base64 to 76 characters per line
|
||||
type base64LineWriter struct { |
||||
w io.Writer |
||||
lineLen int |
||||
} |
||||
|
||||
func newBase64LineWriter(w io.Writer) *base64LineWriter { |
||||
return &base64LineWriter{w: w} |
||||
} |
||||
|
||||
func (w *base64LineWriter) Write(p []byte) (int, error) { |
||||
n := 0 |
||||
for len(p)+w.lineLen > maxLineLen { |
||||
w.w.Write(p[:maxLineLen-w.lineLen]) |
||||
w.w.Write([]byte("\r\n")) |
||||
p = p[maxLineLen-w.lineLen:] |
||||
n += maxLineLen - w.lineLen |
||||
w.lineLen = 0 |
||||
} |
||||
|
||||
w.w.Write(p) |
||||
w.lineLen += len(p) |
||||
|
||||
return n + len(p), nil |
||||
} |
||||
|
||||
// Stubbed out for testing.
|
||||
var now = time.Now |
||||
@ -0,0 +1,84 @@
|
||||
package email |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"errors" |
||||
|
||||
"gopkg.in/gomail.v2" |
||||
) |
||||
|
||||
const ( |
||||
SmtpEmailerType = "smtp" |
||||
) |
||||
|
||||
func init() { |
||||
RegisterEmailerConfigType(SmtpEmailerType, func() EmailerConfig { return &SmtpEmailerConfig{} }) |
||||
} |
||||
|
||||
type SmtpEmailerConfig struct { |
||||
Host string `json:"host"` |
||||
Port int `json:"port"` |
||||
Auth string `json:"auth"` |
||||
Username string `json:"username"` |
||||
Password string `json:"password"` |
||||
} |
||||
|
||||
func (cfg SmtpEmailerConfig) EmailerType() string { |
||||
return SmtpEmailerType |
||||
} |
||||
|
||||
func (cfg SmtpEmailerConfig) EmailerID() string { |
||||
return SmtpEmailerType |
||||
} |
||||
|
||||
func (cfg SmtpEmailerConfig) Emailer() (Emailer, error) { |
||||
var dialer *gomail.Dialer |
||||
if cfg.Auth == "plain" { |
||||
dialer = gomail.NewPlainDialer(cfg.Host, cfg.Port, cfg.Username, cfg.Password) |
||||
} else { |
||||
dialer = &gomail.Dialer{ |
||||
Host: cfg.Host, |
||||
Port: cfg.Port, |
||||
} |
||||
} |
||||
return &smtpEmailer{ |
||||
dialer: dialer, |
||||
}, nil |
||||
} |
||||
|
||||
type smtpEmailerConfig SmtpEmailerConfig |
||||
|
||||
func (cfg *SmtpEmailerConfig) UnmarshalJSON(data []byte) error { |
||||
smtpCfg := smtpEmailerConfig{} |
||||
err := json.Unmarshal(data, &smtpCfg) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if smtpCfg.Host == "" { |
||||
return errors.New("must set SMTP host") |
||||
} |
||||
if smtpCfg.Port == "" { |
||||
return errors.New("must set SMTP port") |
||||
} |
||||
*cfg = SmtpEmailerConfig(smtpCfg) |
||||
return nil |
||||
} |
||||
|
||||
type smtpEmailer struct { |
||||
dialer *gomail.Dialer |
||||
} |
||||
|
||||
func (emailer *smtpEmailer) SendMail(from, subject, text, html string, to ...string) error { |
||||
msg := gomail.NewMessage() |
||||
msg.SetHeader("From", from) |
||||
msg.SetHeader("To", to...) |
||||
msg.SetHeader("Subject", subject) |
||||
msg.SetBody("text/plain", text) |
||||
msg.SetBody("text/html", html) |
||||
err := emailer.dialer.DialAndSend(msg) |
||||
if err != nil { |
||||
counterEmailSendErr.Add(1) |
||||
return err |
||||
} |
||||
return nil |
||||
} |
||||
Loading…
Reference in new issue