Initial commit

This commit is contained in:
alexcesaro 2014-10-15 17:47:07 +02:00
commit 707e81ecee
6 changed files with 1376 additions and 0 deletions

20
LICENSE Normal file
View File

@ -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.

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# Gomail
## Introduction
Package gomail provides a simple interface to send emails.
It requires Go 1.2 or newer.
## Features
* Dead-simple API
* Highly flexible
* Backward compatibility promise
* Supports HTML and text templates
* Attachments
* Embedded images
* Automatic encoding of special characters
* Well-documented
* High test coverage
## Documentation
https://godoc.org/gopkg.in/gomail.v1
## Download
go get gopkg.in/gomail.v1
## Example
package main
import (
"gopkg.in/gomail.v1"
)
func main() {
msg := gomail.NewMessage()
msg.SetHeader("From", "alex@example.com")
msg.SetHeader("To", "bob@example.com", "cora@example.com")
msg.SetAddressHeader("Cc", "dan@example.com", "Dan")
msg.SetHeader("Subject", "Hello!")
msg.SetBody("text/html", "Hello <b>Bob</b> and <i>Cora</i>!")
f, err := gomail.OpenFile("/home/Alex/lolcat.jpg")
if err != nil {
panic(err)
}
msg.Attach(f)
// Send the email to Bob, Cora and Dan
mailer := gomail.NewMailer("smtp.example.com", "user", "123456", 25)
if err := mailer.Send(msg); err != nil {
panic(err)
}
}
## Contact
You are more than welcome to open issues and send pull requests if you find a
bug or need a new feature.
You can also ask questions on the [Gomail
thread](https://groups.google.com/d/topic/golang-nuts/ywPpNlmSt6U/discussion)
in the Go mailing-list or via Twitter [@alexandrecesaro](https://twitter.com/alexandrecesaro).

259
export.go Normal file
View File

@ -0,0 +1,259 @@
package gomail
import (
"bytes"
"encoding/base64"
"io"
"mime/multipart"
"net/mail"
"time"
"gopkg.in/alexcesaro/quotedprintable.v1"
)
// Export converts the message into a net/mail.Message.
func (msg *Message) Export() *mail.Message {
w := newMessageWriter(msg)
if msg.hasMixedPart() {
w.openMultipart("mixed")
}
if msg.hasRelatedPart() {
w.openMultipart("related")
}
if msg.hasAlternativePart() {
w.openMultipart("alternative")
}
for _, part := range msg.parts {
h := make(map[string][]string)
h["Content-Type"] = []string{part.contentType + "; charset=" + msg.charset}
if msg.encoding == Base64 {
h["Content-Transfer-Encoding"] = []string{string(Base64)}
} else {
h["Content-Transfer-Encoding"] = []string{string(QuotedPrintable)}
}
w.write(h, part.body.Bytes(), msg.encoding)
}
if msg.hasAlternativePart() {
w.closeMultipart()
}
w.addFiles(msg.embedded, false)
if msg.hasRelatedPart() {
w.closeMultipart()
}
w.addFiles(msg.attachments, true)
if msg.hasMixedPart() {
w.closeMultipart()
}
return w.export()
}
func (msg *Message) hasMixedPart() bool {
return (len(msg.parts) > 0 && len(msg.attachments) > 0) || len(msg.attachments) > 1
}
func (msg *Message) hasRelatedPart() bool {
return (len(msg.parts) > 0 && len(msg.embedded) > 0) || len(msg.embedded) > 1
}
func (msg *Message) hasAlternativePart() bool {
return len(msg.parts) > 1
}
// messageWriter helps converting the message into a net/mail.Message
type messageWriter struct {
header map[string][]string
buf *bytes.Buffer
writers [3]*multipart.Writer
partWriter io.Writer
depth uint8
}
func newMessageWriter(msg *Message) *messageWriter {
// We copy the header so Export does not modify the message
header := make(map[string][]string, len(msg.header)+2)
for k, v := range msg.header {
header[k] = v
}
if _, ok := header["Mime-Version"]; !ok {
header["Mime-Version"] = []string{"1.0"}
}
if _, ok := header["Date"]; !ok {
header["Date"] = []string{msg.FormatDate(now())}
}
return &messageWriter{header: header, buf: new(bytes.Buffer)}
}
// Stubbed out for testing.
var now = time.Now
func (w *messageWriter) openMultipart(mimeType string) {
w.writers[w.depth] = multipart.NewWriter(w.buf)
contentType := "multipart/" + mimeType + "; boundary=" + w.writers[w.depth].Boundary()
if w.depth == 0 {
w.header["Content-Type"] = []string{contentType}
} else {
h := make(map[string][]string)
h["Content-Type"] = []string{contentType}
w.createPart(h)
}
w.depth++
}
func (w *messageWriter) createPart(h map[string][]string) {
// No need to check the error since the underlying writer is a bytes.Buffer
w.partWriter, _ = 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 {
h := make(map[string][]string)
h["Content-Type"] = []string{f.MimeType + "; name=\"" + f.Name + "\""}
h["Content-Transfer-Encoding"] = []string{string(Base64)}
if isAttachment {
h["Content-Disposition"] = []string{"attachment; filename=\"" + f.Name + "\""}
} else {
h["Content-Disposition"] = []string{"inline; filename=\"" + f.Name + "\""}
h["Content-ID"] = []string{"<" + f.Name + ">"}
}
w.write(h, f.Content, Base64)
}
}
func (w *messageWriter) write(h map[string][]string, body []byte, enc Encoding) {
w.writeHeader(h)
w.writeBody(body, enc)
}
func (w *messageWriter) writeHeader(h map[string][]string) {
if w.depth == 0 {
for field, value := range h {
w.header[field] = value
}
} else {
w.createPart(h)
}
}
func (w *messageWriter) writeBody(body []byte, enc Encoding) {
var subWriter io.Writer
if w.depth == 0 {
subWriter = w.buf
} else {
subWriter = w.partWriter
}
// The errors returned by writers are not checked since these writers cannot
// return errors.
if enc == Base64 {
writer := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter))
writer.Write(body)
writer.Close()
} else {
writer := quotedprintable.NewEncoder(newQpLineWriter(subWriter))
writer.Write(body)
}
}
func (w *messageWriter) export() *mail.Message {
return &mail.Message{Header: w.header, Body: w.buf}
}
// As defined in RFC 5322, 2.1.1.
const maxLineLen = 78
// base64LineWriter limits text encoded in base64 to 78 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
}
// qpLineWriter limits text encoded in quoted-printable to 78 characters per
// line
type qpLineWriter struct {
w io.Writer
lineLen int
}
func newQpLineWriter(w io.Writer) *qpLineWriter {
return &qpLineWriter{w: w}
}
func (w *qpLineWriter) Write(p []byte) (int, error) {
n := 0
for len(p) > 0 {
// If the text is not over the limit, write everything
if len(p) < maxLineLen-w.lineLen {
w.w.Write(p)
w.lineLen += len(p)
return n + len(p), nil
}
i := bytes.IndexAny(p[:maxLineLen-w.lineLen+2], "\n")
// If there is a newline before the limit, write the end of the line
if i != -1 && (i != maxLineLen-w.lineLen+1 || p[i-1] == '\r') {
w.w.Write(p[:i+1])
p = p[i+1:]
n += i + 1
w.lineLen = 0
continue
}
// Quoted-printable text must not be cut between an equal sign and the
// two following characters
var toWrite int
if maxLineLen-w.lineLen-2 >= 0 && p[maxLineLen-w.lineLen-2] == '=' {
toWrite = maxLineLen - w.lineLen - 2
} else if p[maxLineLen-w.lineLen-1] == '=' {
toWrite = maxLineLen - w.lineLen - 1
} else {
toWrite = maxLineLen - w.lineLen
}
// Insert the newline where it is needed
w.w.Write(p[:toWrite])
w.w.Write([]byte("=\r\n"))
p = p[toWrite:]
n += toWrite
w.lineLen = 0
}
return n, nil
}

267
gomail.go Normal file
View File

@ -0,0 +1,267 @@
// Package gomail provides a simple interface to send emails.
//
// More info on Github: https://github.com/go-gomail/gomail
package gomail
import (
"bytes"
"io"
"io/ioutil"
"mime"
"path/filepath"
"time"
"gopkg.in/alexcesaro/quotedprintable.v1"
)
// Message represents an email.
type Message struct {
header header
parts []part
attachments []*File
embedded []*File
charset string
encoding Encoding
hEncoder *quotedprintable.HeaderEncoder
}
type header map[string][]string
type part struct {
contentType string
body *bytes.Buffer
}
// NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding
// by default.
func NewMessage(settings ...MessageSetting) *Message {
msg := &Message{
header: make(header),
charset: "UTF-8",
encoding: QuotedPrintable,
}
msg.applySettings(settings)
var e quotedprintable.Encoding
if msg.encoding == Base64 {
e = quotedprintable.B
} else {
e = quotedprintable.Q
}
msg.hEncoder = e.NewHeaderEncoder(msg.charset)
return msg
}
func (msg *Message) applySettings(settings []MessageSetting) {
for _, s := range settings {
s(msg)
}
}
// A MessageSetting can be used as an argument in NewMessage to configure an
// email.
type MessageSetting func(msg *Message)
// SetCharset is a message setting to set the charset of the email.
//
// Example:
//
// msg := NewMessage(SetCharset("ISO-8859-1"))
func SetCharset(charset string) MessageSetting {
return func(msg *Message) {
msg.charset = charset
}
}
// SetEncoding is a message setting to set the encoding of the email.
//
// Example:
//
// msg := NewMessage(SetEncoding(gomail.Base64))
func SetEncoding(enc Encoding) MessageSetting {
return func(msg *Message) {
msg.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"
)
// SetHeader sets a value to the given header field.
func (msg *Message) SetHeader(field string, value ...string) {
for i := range value {
value[i] = msg.encodeHeader(value[i])
}
msg.header[field] = value
}
func (msg *Message) encodeHeader(value string) string {
return msg.hEncoder.Encode(value)
}
// SetHeaders sets the message headers.
//
// Example:
//
// msg.SetHeaders(map[string][]string{
// "From": {"alex@example.com"},
// "To": {"bob@example.com", "cora@example.com"},
// "Subject": {"Hello"},
// })
func (msg *Message) SetHeaders(h map[string][]string) {
for k, v := range h {
msg.SetHeader(k, v...)
}
}
// SetAddressHeader sets an address to the given header field.
func (msg *Message) SetAddressHeader(field, address, name string) {
msg.header[field] = []string{msg.FormatAddress(address, name)}
}
// FormatAddress formats an address and a name as a valid RFC 5322 address.
func (msg *Message) FormatAddress(address, name string) string {
return msg.encodeHeader(name) + " <" + address + ">"
}
// SetDateHeader sets a date to the given header field.
func (msg *Message) SetDateHeader(field string, date time.Time) {
msg.header[field] = []string{msg.FormatDate(date)}
}
// FormatDate formats a date as a valid RFC 5322 date.
func (msg *Message) FormatDate(date time.Time) string {
return date.Format(time.RFC822)
}
// GetHeader gets a header field.
func (msg *Message) GetHeader(field string) []string {
return msg.header[field]
}
// DelHeader deletes a header field.
func (msg *Message) DelHeader(field string) {
delete(msg.header, field)
}
// SetBody sets the body of the message.
func (msg *Message) SetBody(contentType, body string) {
msg.parts = []part{
part{
contentType: contentType,
body: bytes.NewBufferString(body),
},
}
}
// AddAlternative adds an alternative body to the message. Commonly used to
// send HTML emails that default to the plain text version for backward
// compatibility.
//
// Example:
//
// msg.SetBody("text/plain", "Hello!")
// msg.AddAlternative("text/html", "<p>Hello!</p>")
//
// More info: http://en.wikipedia.org/wiki/MIME#Alternative
func (msg *Message) AddAlternative(contentType, body string) {
msg.parts = append(msg.parts,
part{
contentType: contentType,
body: bytes.NewBufferString(body),
},
)
}
// GetBodyWriter gets a writer that writes to the body. It can be useful with
// the templates from packages text/template or html/template.
//
// Example:
//
// w := msg.GetBodyWriter("text/plain")
// t := template.Must(template.New("example").Parse("Hello {{.}}!"))
// t.Execute(w, "Bob")
func (msg *Message) GetBodyWriter(contentType string) io.Writer {
buf := new(bytes.Buffer)
msg.parts = append(msg.parts,
part{
contentType: contentType,
body: buf,
},
)
return buf
}
// A File represents a file that can be attached or embedded in an email.
type File struct {
Name string
MimeType string
Content []byte
}
// OpenFile opens a file on disk to create a gomail.File.
func OpenFile(filename string) (*File, error) {
content, err := readFile(filename)
if err != nil {
return nil, err
}
f := CreateFile(filepath.Base(filename), content)
return f, nil
}
// CreateFile creates a gomail.File from the given name and content.
func CreateFile(name string, content []byte) *File {
mimeType := mime.TypeByExtension(filepath.Ext(name))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &File{
Name: name,
MimeType: mimeType,
Content: content,
}
}
// Attach attaches the files to the email.
func (msg *Message) Attach(f ...*File) {
if msg.attachments == nil {
msg.attachments = f
} else {
msg.attachments = append(msg.attachments, f...)
}
}
// Embed embeds the images to the email.
//
// Example:
//
// f, err := gomail.OpenFile("/tmp/image.jpg")
// if err != nil {
// panic(err)
// }
// msg.Embed(f)
// msg.SetBody("text/html", `<img src="cid:image.jpg" alt="My image" />`)
func (msg *Message) Embed(image ...*File) {
if msg.embedded == nil {
msg.embedded = image
} else {
msg.embedded = append(msg.attachments, image...)
}
}
// Stubbed out for testing.
var readFile = ioutil.ReadFile

600
gomail_test.go Normal file
View File

@ -0,0 +1,600 @@
package gomail
import (
"encoding/base64"
"net/smtp"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
type message struct {
from string
to []string
content string
}
func TestMessage(t *testing.T) {
msg := NewMessage()
msg.SetAddressHeader("From", "from@example.com", "Señor From")
msg.SetHeader("To", msg.FormatAddress("to@example.com", "Señor To"), "tobis@example.com")
msg.SetDateHeader("X-Date", stubNow())
msg.SetHeader("X-Date-2", msg.FormatDate(stubNow()))
msg.SetHeader("Subject", "¡Hola, señor!")
msg.SetHeaders(map[string][]string{
"X-Headers": {"Test", "Café"},
})
msg.SetBody("text/plain", "¡Hola, señor!")
want := message{
from: "=?UTF-8?Q?Se=C3=B1or_From?= <from@example.com>",
to: []string{
"=?UTF-8?Q?Se=C3=B1or_To?= <to@example.com>",
"tobis@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" +
"X-Date: 25 Jun 14 17:46 UTC\r\n" +
"X-Date-2: 25 Jun 14 17:46 UTC\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, msg, 0, want)
}
func TestBodyWriter(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
w := msg.GetBodyWriter("text/plain")
w.Write([]byte("Test message"))
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, msg, 0, want)
}
func TestCustomMessage(t *testing.T) {
msg := NewMessage(SetCharset("ISO-8859-1"), SetEncoding(Base64))
msg.SetHeaders(map[string][]string{
"From": {"from@example.com"},
"To": {"to@example.com"},
"Subject": {"Café"},
})
msg.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, msg, 0, want)
}
func TestRecipients(t *testing.T) {
msg := NewMessage()
msg.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!"},
})
msg.SetBody("text/plain", "Test message")
want := message{
from: "from@example.com",
to: []string{"to@example.com", "cc@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",
}
wantBcc1 := message{
from: "from@example.com",
to: []string{"bcc1@example.com"},
content: "From: from@example.com\r\n" +
"To: to@example.com\r\n" +
"Cc: cc@example.com\r\n" +
"Bcc: bcc1@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",
}
wantBcc2 := message{
from: "from@example.com",
to: []string{"bcc2@example.com"},
content: "From: from@example.com\r\n" +
"To: to@example.com\r\n" +
"Cc: cc@example.com\r\n" +
"Bcc: bcc2@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, msg, 0, want, wantBcc1, wantBcc2)
}
func TestAlternative(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.SetBody("text/plain", "¡Hola, señor!")
msg.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, msg, 1, want)
}
func TestAttachmentOnly(t *testing.T) {
readFile = func(filename string) ([]byte, error) {
return []byte("Content of " + filepath.Base(filename)), nil
}
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
f, err := OpenFile("/tmp/test.pdf")
if err != nil {
panic(err)
}
msg.Attach(f)
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, msg, 0, want)
}
func TestAttachment(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.SetBody("text/plain", "Test")
msg.Attach(CreateFile("test.pdf", []byte("Content")))
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")) + "\r\n" +
"--_BOUNDARY_1_--\r\n",
}
testMessage(t, msg, 1, want)
}
func TestAttachmentsOnly(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.Attach(CreateFile("test.pdf", []byte("Content 1")))
msg.Attach(CreateFile("test.zip", []byte("Content 2")))
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 1")) + "\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 2")) + "\r\n" +
"--_BOUNDARY_1_--\r\n",
}
testMessage(t, msg, 1, want)
}
func TestAttachments(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.SetBody("text/plain", "Test")
msg.Attach(CreateFile("test.pdf", []byte("Content 1")))
msg.Attach(CreateFile("test.zip", []byte("Content 2")))
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 1")) + "\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 2")) + "\r\n" +
"--_BOUNDARY_1_--\r\n",
}
testMessage(t, msg, 1, want)
}
func TestEmbedded(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.Embed(CreateFile("image.jpg", []byte("Content")))
msg.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=\"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")) + "\r\n" +
"--_BOUNDARY_1_--\r\n",
}
testMessage(t, msg, 1, want)
}
func TestFullMessage(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.SetBody("text/plain", "¡Hola, señor!")
msg.AddAlternative("text/html", "¡<b>Hola</b>, <i>señor</i>!</h1>")
msg.Attach(CreateFile("test.pdf", []byte("Content 1")))
msg.Embed(CreateFile("image.jpg", []byte("Content 2")))
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 2")) + "\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 1")) + "\r\n" +
"--_BOUNDARY_1_--\r\n",
}
testMessage(t, msg, 3, want)
}
func TestQpLineLength(t *testing.T) {
msg := NewMessage()
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.SetBody("text/plain",
strings.Repeat("0", 79)+"\r\n"+
strings.Repeat("0", 78)+"à\r\n"+
strings.Repeat("0", 77)+"à\r\n"+
strings.Repeat("0", 76)+"à\r\n"+
strings.Repeat("0", 75)+"à\r\n"+
strings.Repeat("0", 78)+"\r\n"+
strings.Repeat("0", 79)+"\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", 78) + "=\r\n0\r\n" +
strings.Repeat("0", 78) + "=\r\n=C3=A0\r\n" +
strings.Repeat("0", 77) + "=\r\n=C3=A0\r\n" +
strings.Repeat("0", 76) + "=\r\n=C3=A0\r\n" +
strings.Repeat("0", 75) + "=C3=\r\n=A0\r\n" +
strings.Repeat("0", 78) + "\r\n" +
strings.Repeat("0", 78) + "=\r\n0\n",
}
testMessage(t, msg, 0, want)
}
func TestBase64LineLength(t *testing.T) {
msg := NewMessage(SetCharset("UTF-8"), SetEncoding(Base64))
msg.SetHeader("From", "from@example.com")
msg.SetHeader("To", "to@example.com")
msg.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) + "MA\r\n==",
}
testMessage(t, msg, 0, want)
}
func testMessage(t *testing.T, msg *Message, bCount int, emails ...message) {
now = stubNow
mailer := NewMailer("host", "username", "password", 25, SetSendMail(stubSendMail(t, bCount, emails...)))
err := mailer.Send(msg)
if err != nil {
t.Error(err)
}
}
func stubNow() time.Time {
return time.Date(2014, 06, 25, 17, 46, 0, 0, time.UTC)
}
func stubSendMail(t *testing.T, bCount int, emails ...message) SendMailFunc {
i := 0
return func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
if i > len(emails) {
t.Fatalf("Only %d mails should be sent", len(emails))
}
want := emails[i]
if addr != "host:25" {
t.Fatalf("Invalid address, got %q, want host:25", addr)
}
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],
)
}
}
got := string(msg)
wantMsg := string("Mime-Version: 1.0\r\n" +
"Date: 25 Jun 14 17:46 UTC\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)
}
}
i++
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, msg string) []string {
if matches := boundaryRegExp.FindAllStringSubmatch(msg, 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 BenchmarkFull(b *testing.B) {
emptyFunc := func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
return nil
}
for n := 0; n < b.N; n++ {
msg := NewMessage()
msg.SetAddressHeader("From", "from@example.com", "Señor From")
msg.SetHeaders(map[string][]string{
"To": {"to@example.com"},
"Cc": {"cc@example.com"},
"Bcc": {"bcc1@example.com", "bcc2@example.com"},
"Subject": {"¡Hola, señor!"},
})
msg.SetBody("text/plain", "¡Hola, señor!")
msg.AddAlternative("text/html", "<p>¡Hola, señor!</p>")
msg.Attach(CreateFile("benchmark.txt", []byte("Benchmark")))
msg.Embed(CreateFile("benchmark.jpg", []byte("Benchmark")))
mailer := NewMailer("host", "username", "password", 25, SetSendMail(emptyFunc))
if err := mailer.Send(msg); err != nil {
panic(err)
}
}
}

160
mailer.go Normal file
View File

@ -0,0 +1,160 @@
package gomail
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/mail"
"net/smtp"
"strings"
)
// A Mailer represents an SMTP server.
type Mailer struct {
addr string
auth smtp.Auth
send SendMailFunc
}
// A MailerSetting can be used in a mailer constructor to configure it.
type MailerSetting func(m *Mailer)
// SetSendMail is an option to set the email-sending function of a mailer.
//
// Example:
//
// myFunc := func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
// // Implement your email-sending function similar to smtp.SendMail
// }
// mailer := NewMailer("host", "username", "password", 25, SetSendMail(myFunc))
func SetSendMail(s SendMailFunc) MailerSetting {
return func(m *Mailer) {
m.send = s
}
}
// A SendMailFunc is a function to send emails with the same signature than
// smtp.SendMail.
type SendMailFunc func(addr string, a smtp.Auth, from string, to []string, msg []byte) error
// NewMailer returns a mailer. The given parameters are used to connect to the
// SMTP server via a PLAIN authentication mechanism.
func NewMailer(host string, username string, password string, port int, settings ...MailerSetting) *Mailer {
return NewCustomMailer(
fmt.Sprintf("%s:%d", host, port),
smtp.PlainAuth("", username, password, host),
settings...,
)
}
// NewCustomMailer creates a mailer with the given authentication mechanism.
//
// Example:
//
// gomail.NewCustomMailer("host:25", smtp.CRAMMD5Auth("username", "secret"))
func NewCustomMailer(addr string, auth smtp.Auth, settings ...MailerSetting) *Mailer {
m := &Mailer{
addr: addr,
auth: auth,
send: smtp.SendMail,
}
m.applySettings(settings)
return m
}
func (m *Mailer) applySettings(settings []MailerSetting) {
for _, s := range settings {
s(m)
}
}
// Send sends the emails to all the recipients of the message.
func (m *Mailer) Send(msg *Message) error {
message := msg.Export()
from, err := getFrom(message)
if err != nil {
return err
}
recipients, bcc := getRecipients(message)
h := flattenHeader(message, "")
body, err := ioutil.ReadAll(message.Body)
if err != nil {
return err
}
mail := append(h, body...)
if err := m.send(m.addr, m.auth, from, recipients, mail); err != nil {
return err
}
for _, to := range bcc {
h = flattenHeader(message, to)
mail = append(h, body...)
if err := m.send(m.addr, m.auth, from, []string{to}, mail); err != nil {
return err
}
}
return nil
}
func flattenHeader(msg *mail.Message, bcc string) []byte {
var buf bytes.Buffer
for field, value := range msg.Header {
if field != "Bcc" {
buf.WriteString(field + ": " + strings.Join(value, ", ") + "\r\n")
} else if bcc != "" {
for _, to := range value {
if strings.Contains(to, bcc) {
buf.WriteString(field + ": " + to + "\r\n")
}
}
}
}
buf.WriteString("\r\n")
return buf.Bytes()
}
func getFrom(msg *mail.Message) (string, error) {
from := msg.Header.Get("Sender")
if from == "" {
from = msg.Header.Get("From")
if from == "" {
return "", errors.New("mailer: invalid message, \"From\" field is absent")
}
}
return from, nil
}
func getRecipients(msg *mail.Message) (recipients, bcc []string) {
for _, field := range []string{"Bcc", "To", "Cc"} {
if addresses, ok := msg.Header[field]; ok {
for _, addr := range addresses {
switch field {
case "Bcc":
bcc = addAdress(bcc, addr)
default:
recipients = addAdress(recipients, addr)
}
}
}
}
return recipients, bcc
}
func addAdress(list []string, addr string) []string {
for _, a := range list {
if addr == a {
return list
}
}
return append(list, addr)
}