From 707e81ecee20d0f75d33eec639a88aaec8e6e283 Mon Sep 17 00:00:00 2001 From: alexcesaro Date: Wed, 15 Oct 2014 17:47:07 +0200 Subject: [PATCH] Initial commit --- LICENSE | 20 ++ README.md | 70 ++++++ export.go | 259 +++++++++++++++++++++ gomail.go | 267 ++++++++++++++++++++++ gomail_test.go | 600 +++++++++++++++++++++++++++++++++++++++++++++++++ mailer.go | 160 +++++++++++++ 6 files changed, 1376 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 export.go create mode 100644 gomail.go create mode 100644 gomail_test.go create mode 100644 mailer.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5f5c12a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d26eec --- /dev/null +++ b/README.md @@ -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 Bob and Cora!") + + 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). diff --git a/export.go b/export.go new file mode 100644 index 0000000..99038ad --- /dev/null +++ b/export.go @@ -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 +} diff --git a/gomail.go b/gomail.go new file mode 100644 index 0000000..f491a5e --- /dev/null +++ b/gomail.go @@ -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", "

Hello!

") +// +// 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", `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 diff --git a/gomail_test.go b/gomail_test.go new file mode 100644 index 0000000..ddf25d7 --- /dev/null +++ b/gomail_test.go @@ -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?= ", + to: []string{ + "=?UTF-8?Q?Se=C3=B1or_To?= ", + "tobis@example.com", + }, + content: "From: =?UTF-8?Q?Se=C3=B1or_From?= \r\n" + + "To: =?UTF-8?Q?Se=C3=B1or_To?= , 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", "¡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" + + "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=A1Hola, se=C3=B1or!\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: \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", "¡Hola, señor!") + 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=A1Hola, se=C3=B1or!\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: \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", "

¡Hola, señor!

") + 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) + } + } +} diff --git a/mailer.go b/mailer.go new file mode 100644 index 0000000..81ceb42 --- /dev/null +++ b/mailer.go @@ -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) +}