diff --git a/main.go b/main.go index cc485b3..e6e9a94 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ import ( const crlf = "\r\n" -var ErrMissingRecipient = errors.New("No recipient specified. At one To, Cc, or Bcc recipient is required.") +var ErrMissingRecipient = errors.New("No recipient specified. At least one To, Cc, or Bcc recipient is required.") var ErrMissingFromAddress = errors.New("No from address specified.") // A Message represents an email message. @@ -177,88 +177,122 @@ func (m *Message) Bytes() ([]byte, error) { header[k] = v } - // Top level multipart writer for our `multipart/mixed` body. - mixedw := multipart.NewWriter(buffer) - header.Add("MIME-Version", "1.0") - header.Add("Content-Type", fmt.Sprintf("multipart/mixed;%s boundary=%s", crlf, mixedw.Boundary())) - - err = writeHeader(buffer, header) - if err != nil { - return nil, err - } - // Write the start of our `multipart/mixed` body. - _, err = fmt.Fprintf(buffer, "--%s%s", mixedw.Boundary(), crlf) - if err != nil { - return nil, err + var mixedw *multipart.Writer + var hasAttachments = m.Attachments != nil && len(m.Attachments) > 0 + if hasAttachments { + // Top level multipart writer for our `multipart/mixed` body. + // only needed if we have attachments + mixedw = multipart.NewWriter(buffer) + header.Add("Content-Type", fmt.Sprintf("multipart/mixed;%s boundary=%s", crlf, mixedw.Boundary())) + err = writeHeader(buffer, header) + if err != nil { + return nil, err + } + header = textproto.MIMEHeader{} + // Write the start of our `multipart/mixed` body. + _, err = fmt.Fprintf(buffer, "--%s%s", mixedw.Boundary(), crlf) + if err != nil { + return nil, err + } } - // Does the message have a body? - if m.Body != "" || m.HTMLBody != "" { - + var altw *multipart.Writer + if m.Body != "" && m.HTMLBody != "" { // Nested multipart writer for our `multipart/alternative` body. - altw := multipart.NewWriter(buffer) + altw = multipart.NewWriter(buffer) - header = textproto.MIMEHeader{} header.Add("Content-Type", fmt.Sprintf("multipart/alternative;%s boundary=%s", crlf, altw.Boundary())) err := writeHeader(buffer, header) if err != nil { return nil, err } + } - if m.Body != "" { + // Only include an empty plain text body if the html body is also empty. + if m.Body != "" || m.Body == "" && m.HTMLBody == "" { + if altw != nil { header = textproto.MIMEHeader{} - header.Add("Content-Type", "text/plain; charset=utf-8") - header.Add("Content-Transfer-Encoding", "quoted-printable") - //header.Add("Content-Transfer-Encoding", "base64") + } + header.Add("Content-Type", "text/plain; charset=utf-8") + header.Add("Content-Transfer-Encoding", "quoted-printable") + //header.Add("Content-Transfer-Encoding", "base64") + var writer io.Writer + if altw != nil { partw, err := altw.CreatePart(header) if err != nil { return nil, err } - - bodyBytes := []byte(m.Body) - //encoder := NewBase64MimeEncoder(partw) - encoder := qprintable.NewEncoder(qprintable.DetectEncoding(m.Body), partw) - _, err = encoder.Write(bodyBytes) - if err != nil { - return nil, err - } - err = encoder.Close() + writer = partw + } else { + writer = buffer + err = writeHeader(buffer, header) if err != nil { return nil, err } } - if m.HTMLBody != "" { + bodyBytes := []byte(m.Body) + //encoder := NewBase64MimeEncoder(writer) + encoder := qprintable.NewEncoder(qprintable.DetectEncoding(m.Body), writer) + _, err = encoder.Write(bodyBytes) + if err != nil { + return nil, err + } + err = encoder.Close() + if err != nil { + return nil, err + } + } + + if m.HTMLBody != "" { + if altw != nil { header = textproto.MIMEHeader{} - header.Add("Content-Type", "text/html; charset=utf-8") - //header.Add("Content-Transfer-Encoding", "quoted-printable") - header.Add("Content-Transfer-Encoding", "base64") + } + header.Add("Content-Type", "text/html; charset=utf-8") + //header.Add("Content-Transfer-Encoding", "quoted-printable") + header.Add("Content-Transfer-Encoding", "base64") + var writer io.Writer + if altw != nil { partw, err := altw.CreatePart(header) if err != nil { return nil, err } - - htmlBodyBytes := []byte(m.HTMLBody) - encoder := NewBase64MimeEncoder(partw) - //encoder := qprintable.NewEncoder(qprintable.DetectEncoding(m.HTMLBody), partw) - _, err = encoder.Write(htmlBodyBytes) - if err != nil { - return nil, err - } - err = encoder.Close() + writer = partw + } else { + writer = buffer + err = writeHeader(buffer, header) if err != nil { return nil, err } } + htmlBodyBytes := []byte(m.HTMLBody) + encoder := NewBase64MimeEncoder(writer) + //encoder := qprintable.NewEncoder(qprintable.DetectEncoding(m.HTMLBody), writer) + _, err = encoder.Write(htmlBodyBytes) + if err != nil { + return nil, err + } + err = encoder.Close() + if err != nil { + return nil, err + } + } + + if altw != nil { altw.Close() + } else { + _, err = fmt.Fprintf(buffer, "%s", crlf) + if err != nil { + return nil, err + } } - if m.Attachments != nil && len(m.Attachments) > 0 { + if hasAttachments { for _, attachment := range m.Attachments { @@ -293,10 +327,9 @@ func (m *Message) Bytes() ([]byte, error) { } } + mixedw.Close() } - mixedw.Close() - return buffer.Bytes(), nil } diff --git a/main_test.go b/main_test.go index 4b006fa..5f6116b 100644 --- a/main_test.go +++ b/main_test.go @@ -1,31 +1,224 @@ package gophermail import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + . "github.com/onsi/gomega" + "io" + "io/ioutil" + "mime" + "mime/multipart" "net/mail" + "net/textproto" + "strings" "testing" "time" ) -func Test_Bytes(t *testing.T) { +// registerFailHandler registers a gomega fail handler that calls t.Fatal +// gomega.RegisterTestingT calls t.Error, which does not stop the test +func registerFailHandler(t *testing.T) { + RegisterFailHandler(func(message string, callerSkip ...int) { + t.Fatalf("\n%s", message) + }) +} + +// expectNoError fails the test if err is not nil +func expectNoError(err error) { + Expect(err).To(BeNil(), fmt.Sprintf("%v", err)) +} + +// getContentType gets the content type in a header, and parses it +func getContentType(header textproto.MIMEHeader) (string, map[string]string) { + contentType, ok := header["Content-Type"] + Expect(ok).To(BeTrue(), "Content-Type header not found") + Expect(contentType).NotTo(BeEmpty(), "Content-Type header is empty") + Expect(contentType).To(HaveLen(1), "More than one Content-Type header found") + + mediaType, params, err := mime.ParseMediaType(contentType[0]) + expectNoError(err) + + return mediaType, params +} + +func matchBase64(r io.Reader, expected string, msg string) { + base64contents, err := ioutil.ReadAll(r) + expectNoError(err) + + contents, err := base64.StdEncoding.DecodeString(string(base64contents)) + expectNoError(err) + Expect(string(contents)).To(Equal(expected), msg) +} + +// testMail is the main testing function +func testMail(t *testing.T, plain, html, attachment bool) { + registerFailHandler(t) + + // NOTE: QP decoding cuts off trailing whitespace + plainBody := "My Plain Text Body áűőú\n Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n Nunc et purus massa. Maecenas sed ex iaculis, feugiat elit ullamcorper, eleifend elit. Aliquam ultricies libero vitae interdum maximus. Nullam placerat purus dolor, a tempor magna efficitur in. Integer mattis, lacus tempus mattis rutrum, tellus velit ultricies nisl, a elementum dolor nisi sed diam." + htmlBody := "

My HTML Body

\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc et purus massa.

" + filename := "test.txt" + fileContents := "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc et purus massa. Aenean sed enim turpis. Maecenas sed ex iaculis, feugiat elit ullamcorper, eleifend elit. Aliquam ultricies libero vitae interdum maximus. Nullam placerat purus dolor, a tempor magna efficitur in. Integer mattis, lacus tempus mattis rutrum, tellus velit ultricies nisl, a elementum dolor nisi sed diam. Nunc cursus arcu quis sapien dapibus suscipit. Aliquam in dolor ut enim faucibus volutpat vel id ipsum. Aenean blandit ipsum eu bibendum fermentum. Donec sagittis nunc dolor, in bibendum lorem pulvinar et. Nam elementum auctor tempor. Nunc et nisl diam. Pellentesque eget suscipit leo. Sed lacus urna, semper nec tellus a, finibus aliquet odio. Nulla in finibus justo, non congue dui." + m := &Message{} m.SetFrom("Doman Sender ") - m.SetReplyTo("Don't reply ") m.AddTo("First person ") - m.AddTo("to_2@domain.com") - m.AddCc("Less important person ") - m.AddBcc("Sneaky person ") m.Subject = "My Subject (abcdefghijklmnop qrstuvwxyz0123456789 abcdefghijklmnopqrstuvwxyz0123456789_567890)" - m.Body = "My Plain Text Body" - m.HTMLBody = "

My HTML Body

" + if plain { + m.Body = plainBody + } + if html { + m.HTMLBody = htmlBody + } + + if attachment { + m.Attachments = []Attachment{Attachment{ + Name: filename, + ContentType: "text/plain", + Data: strings.NewReader(fileContents), + }} + } + m.Headers = mail.Header{} m.Headers["Date"] = []string{time.Now().UTC().Format(time.RFC822)} - bytes, err := m.Bytes() - if err != nil { - t.Log(err) - t.Fail() + b, err := m.Bytes() + + expectNoError(err) + + t.Logf("Bytes: \n%s", b) + + byteReader := bytes.NewReader(b) + bufReader := bufio.NewReader(byteReader) + headerReader := textproto.NewReader(bufReader) + header, err := headerReader.ReadMIMEHeader() + expectNoError(err) + + mediaType, params := getContentType(header) + + if !attachment && !(plain && html) { + if html { + Expect(mediaType).To(Equal("text/html"), "Content-Type is not text/html") + } else { + Expect(mediaType).To(Equal("text/plain"), "Content-Type is not text/plain") + } + return + } + if attachment { + Expect(mediaType).To(Equal("multipart/mixed"), "Content-Type is not multipart/mixed") + } else { + Expect(mediaType).To(Equal("multipart/alternative"), "Content-Type is not multipart/alternative") + } + Expect(params).To(HaveKey("boundary"), "boundary is missing from Content-Type") + boundary := params["boundary"] + + plainFound := false + htmlFound := false + attachmentFound := false + + var readParts func(io.Reader, string, bool) + readParts = func(r io.Reader, boundary string, toplevel bool) { + multipartReader := multipart.NewReader(r, boundary) + + for { + part, err := multipartReader.NextPart() + if err == io.EOF { + break + } + expectNoError(err) + + t.Logf("part: %v", part) + + mediaType, params := getContentType(part.Header) + + dispositions, ok := part.Header["Content-Disposition"] + if ok && len(dispositions) == 1 { + attachmentMediaType, attachmentParams, err := mime.ParseMediaType(dispositions[0]) + expectNoError(err) + Expect(attachmentMediaType).To(Equal("attachment"), "attachment media type is not \"attachment\"") + Expect(attachmentParams).To(HaveKey("filename"), "filename missing from attachment") + Expect(attachmentParams["filename"]).To(Equal(filename), "filename is incorrect") + Expect(toplevel).To(BeTrue(), "attachment found in multipart/alternative") + + matchBase64(part, fileContents, "attachment does not match") + + attachmentFound = true + } else { + switch mediaType { + case "text/plain": + rawContents, err := ioutil.ReadAll(part) + expectNoError(err) + + contents := strings.Replace(string(rawContents), "\r\n", "\n", -1) + + t.Logf("\n\n%#v\n\n%#v\n\n", contents, plainBody) + expectedBody := plainBody + if !plain { + expectedBody = "" + } + Expect(contents).To(Equal(expectedBody), "plain text body does not match") + + plainFound = true + case "text/html": + matchBase64(part, htmlBody, "html body does not match") + + htmlFound = true + case "multipart/alternative": + Expect(params).To(HaveKey("boundary"), "boundary is missing from Content-Type") + boundary := params["boundary"] + readParts(part, boundary, false) + default: + t.Logf("unexpected media type: %v", mediaType) + } + } + } + } + + readParts(bufReader, boundary, true) + if plain || !plain && !html { + Expect(plainFound).To(BeTrue(), "plain text body not found") + } else { + Expect(plainFound).NotTo(BeTrue(), "plain text body found") + } + if html { + Expect(htmlFound).To(BeTrue(), "html text body not found") + } else { + Expect(htmlFound).NotTo(BeTrue(), "html text body found") + } + if attachment { + Expect(attachmentFound).To(BeTrue(), "attachment not found") + } else { + Expect(attachmentFound).NotTo(BeTrue(), "attachment found") } +} + +func TestPlainBody(t *testing.T) { + testMail(t, true, false, false) +} + +func TestHtmlBody(t *testing.T) { + testMail(t, false, true, false) +} + +func TestAlternativeBody(t *testing.T) { + testMail(t, true, true, false) +} + +func TestPlainAttachment(t *testing.T) { + testMail(t, true, false, true) +} + +func TestHtmlPlainAttachment(t *testing.T) { + testMail(t, true, true, true) +} + +func TestNoBody(t *testing.T) { + testMail(t, false, false, false) +} - t.Logf("%s", bytes) +func TestNoBodyAttachment(t *testing.T) { + testMail(t, false, false, true) } diff --git a/smtp.go b/smtp.go index 9b2ab7d..67048a3 100644 --- a/smtp.go +++ b/smtp.go @@ -1,7 +1,7 @@ package gophermail import ( - "crypto/tls" + "crypto/tls" "net/smtp" ) @@ -31,7 +31,6 @@ func SendMail(addr string, a smtp.Auth, msg *Message) error { return smtp.SendMail(addr, a, msg.From.Address, to, msgBytes) } - // SendTLSMail does the same thing as SendMail, except with the added // option of providing a tls.Config func SendTLSMail(addr string, a smtp.Auth, msg *Message, cfg tls.Config) error { @@ -53,7 +52,7 @@ func SendTLSMail(addr string, a smtp.Auth, msg *Message, cfg tls.Config) error { to = append(to, address.Address) } - from := msg.From.String() + from := msg.From.String() c, err := smtp.Dial(addr) if err != nil { @@ -62,9 +61,9 @@ func SendTLSMail(addr string, a smtp.Auth, msg *Message, cfg tls.Config) error { defer c.Close() if ok, _ := c.Extension("STARTTLS"); ok { - if err = c.StartTLS(&cfg); err != nil { - return err - } + if err = c.StartTLS(&cfg); err != nil { + return err + } } if a != nil {