diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..7bcc3d9
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+- package-ecosystem: gomod
+ directory: "/"
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 99
+ reviewers:
+ - domodwyer
+ assignees:
+ - domodwyer
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..97e28e9
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,40 @@
+
diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml
new file mode 100644
index 0000000..7e1d42c
--- /dev/null
+++ b/.github/workflows/go-test.yml
@@ -0,0 +1,37 @@
+name: unit tests
+
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+jobs:
+
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ go:
+ - '1.8'
+ - '1.9.x'
+ - '1.10.x'
+ - '1.11.x'
+ - '1.12.x'
+ - '1.13.x'
+ - '1.14.x'
+ - '1.15.x'
+
+ name: Go ${{ matrix.go }}
+ steps:
+ - name: Set up Go ${{ matrix.go }}
+ uses: actions/setup-go@v1
+ with:
+ go-version: ${{ matrix.go }}
+ id: go
+
+ - name: Check out code into the Go module directory
+ uses: actions/checkout@v1
+
+ - name: Test
+ run: go test ./... -v
diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml
new file mode 100644
index 0000000..0b229b1
--- /dev/null
+++ b/.github/workflows/lints.yml
@@ -0,0 +1,25 @@
+name: lints
+
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+jobs:
+ pre-commit:
+ runs-on: ubuntu-latest
+ steps:
+ - name: install go-imports
+ run: go install golang.org/x/tools/cmd/goimports@latest
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ - uses: pre-commit/action@v2.0.0
+ with:
+ extra_args: --all-files --hook-stage=manual
+
+ golangci:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v2
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f56a702
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof
+*.pid
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..1e8e192
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,64 @@
+# Run on code only in the new commits
+new: true
+
+run:
+ # Max 1 minute
+ timeout: 10m
+
+ # Return an exit code of 1 when a linter fails
+ issues-exit-code: 1
+
+ # Include test files
+ tests: true
+
+ # Use the vendored 3rd party libs
+ #modules-download-mode: release
+
+# output configuration options
+output:
+ # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number"
+ format: colored-line-number
+
+ # print lines of code with issue, default is true
+ print-issued-lines: true
+
+ # print linter name in the end of issue text, default is true
+ print-linter-name: true
+
+linters:
+ enable:
+ - revive
+ - gosec
+ - unconvert
+ - gocyclo
+ - goimports
+ - nakedret
+ - exportloopref
+ - exhaustive
+ - exportloopref
+
+linters-settings:
+ errcheck:
+ # report about not checking of errors in type assertions: `a := b.(MyStruct)`;
+ # default is false: such cases aren't reported by default.
+ check-type-assertions: true
+
+ exhaustive:
+ default-signifies-exhaustive: true
+ # A default case in a switch covers all remaining variants
+
+issues:
+ exclude-use-default: false
+
+ exclude-rules:
+ # Exclude some linters from running on tests files.
+ - path: (_test\.go)
+ linters:
+ - gocyclo
+ - errcheck
+ - gosec
+
+ # No need for this - errcheck does it.
+ - linters:
+ - gosec
+ text: "G104: Errors unhandled"
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..3592ad9
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,52 @@
+# When running in CI, the "manual" stage is run.
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v2.5.0
+ hooks:
+ - id: check-executables-have-shebangs
+ stages: [commit, manual]
+ exclude: ^vendor/
+
+ - id: check-json
+ stages: [commit, manual]
+
+ - id: check-yaml
+ stages: [commit, manual]
+
+ - id: check-merge-conflict
+ stages: [commit, manual]
+
+ - id: mixed-line-ending
+ args: ["--fix=no"]
+ stages: [commit, manual]
+
+ - id: no-commit-to-branch
+ args: ["--branch", "master", "--branch", "development"]
+ stages: [commit]
+
+ - repo: https://github.com/domodwyer/pre-commit
+ rev: v3.0.1
+ hooks:
+ - id: go-test
+ stages: [commit, manual]
+ types: [go]
+ args: ["-timeout=30s"]
+
+ - id: go-goimports
+ stages: [commit]
+ types: [go]
+
+ - id: go-golangci-lint
+ args: [--new-from-rev=origin/master]
+ stages: [commit, push]
+ types: [go]
+
+ - id: todo-tags
+ stages: [push, manual]
+ types: [go]
+ args: ["--tag=#"]
+
+ - id: todo-branch-tags
+ stages: [post-checkout]
+ args: ["#[0-9]+"]
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..0ad536f
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,7 @@
+language: go
+
+go:
+ - "1.x"
+ - 1.8
+ - 1.9
+ - "1.10.x"
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a1e7afa
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Dom Dwyer
+
+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..0c7c136
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+[](https://travis-ci.org/domodwyer/mailyak)
+[](https://godoc.org/github.com/domodwyer/mailyak)
+
+
+
+
+
+An elegant MIME mail library with support for attachments
+
+
+A simple, easy to use email library for Go (golang).
+
+- Full attachment support (attach anything that implements `io.Reader`)
+- Send to multiple addresses at the same time, including BCC addresses.
+- Supports composing multi-part messages (HTML and plain text emails for older
+ clients)
+- Write templates directly to the email body (implements `io.Writer` for
+ convenience)
+- Production ready - several million emails sent in a production environment
+- SMTP over TLS support, with automatic STARTTLS upgrades for plaintext
+ connections
+
+# Installation
+
+If you're using `go mod`:
+
+```bash
+go get -v github.com/domodwyer/mailyak/v3
+```
+
+Or with `GOPATH`:
+
+```bash
+go get -v github.com/domodwyer/mailyak
+```
+
+# Usage
+
+```Go
+// Create a new email - specify the SMTP host:port and auth (if needed)
+mail := mailyak.New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+
+mail.To("dom@itsallbroken.com")
+mail.From("jsmith@example.com")
+mail.FromName("Bananas for Friends")
+
+mail.Subject("Business proposition")
+
+// mail.HTML() and mail.Plain() implement io.Writer, so you can do handy things like
+// parse a template directly into the email body
+if err := t.ExecuteTemplate(mail.HTML(), "htmlEmail", data); err != nil {
+ panic(" đŁ ")
+}
+
+// Or set the body using a string setter
+mail.Plain().Set("Get a real email client")
+
+// And you're done!
+if err := mail.Send(); err != nil {
+ panic(" đŁ ")
+}
+```
+
+To send an attachment:
+```Go
+mail := mailyak.New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+
+mail.To("dom@itsallbroken.com")
+mail.From("oops@itsallbroken.com")
+mail.Subject("I am a teapot")
+mail.HTML().Set("Don't panic")
+
+// input can be a bytes.Buffer, os.File, os.Stdin, etc.
+// call multiple times to attach multiple files
+mail.Attach("filename.txt", &input)
+
+if err := mail.Send(); err != nil {
+ panic(" đŁ ")
+}
+```
+
+# Notes
+
+- Why "MailYak"? Because "MailyMcMailFace" is annoyingly long to type.
+- You can use a single instance of mailyak to send multiple emails after
+ changing the to/body/whatever fields, avoiding unnecessary allocation/GC
+ pressure.
+- Attachments are read when you call `Send()` to prevent holding onto multiple
+ copies of the attachment in memory (source and email) - this means changing
+ the attachment data between calling `Attach()` and `Send()` will change what's
+ emailed out!
+- For your own sanity you should vendor this, and any other libraries when going
+ into production.
+
+
+### Maintenance Status
+
+This library is fully maintained.
+
+The (relatively) small API/scope and many years spent maturing means it doesn't
+receive frequent code changes any more. Bug fixes are definitely accepted (and
+appreciated!), and you can consider this a stable and maintained library.
\ No newline at end of file
diff --git a/attachments.go b/attachments.go
new file mode 100644
index 0000000..31c6f34
--- /dev/null
+++ b/attachments.go
@@ -0,0 +1,166 @@
+package mailyak
+
+import (
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "net/textproto"
+)
+
+// DetectContentType needs at most 512 bytes
+const sniffLen = 512
+
+type partCreator interface {
+ CreatePart(header textproto.MIMEHeader) (io.Writer, error)
+}
+
+type writeWrapper interface {
+ new(w io.Writer) io.Writer
+}
+
+type attachment struct {
+ filename string
+ content io.Reader
+ inline bool
+ mimeType string
+}
+
+// Attach adds the contents of r to the email as an attachment with name as the
+// filename.
+//
+// r is not read until Send is called and the MIME type will be detected
+// using https://golang.org/pkg/net/http/#DetectContentType
+func (m *MailYak) Attach(name string, r io.Reader) {
+ m.attachments = append(m.attachments, attachment{
+ filename: name,
+ content: r,
+ inline: false,
+ })
+}
+
+// AttachWithMimeType adds the contents of r to the email as an attachment with
+// name as the filename and mimeType as the specified MIME type of the content.
+// It is up to the user to ensure the mimeType is correct.
+//
+// r is not read until Send is called.
+func (m *MailYak) AttachWithMimeType(name string, r io.Reader, mimeType string) {
+ m.attachments = append(m.attachments, attachment{
+ filename: name,
+ content: r,
+ inline: false,
+ mimeType: mimeType,
+ })
+}
+
+// AttachInline adds the contents of r to the email as an inline attachment.
+// Inline attachments are typically used within the email body, such as a logo
+// or header image. It is up to the user to ensure name is unique.
+//
+// Files can be referenced by their name within the email using the cid URL
+// protocol:
+//
+//
+//
+// r is not read until Send is called and the MIME type will be detected
+// using https://golang.org/pkg/net/http/#DetectContentType
+func (m *MailYak) AttachInline(name string, r io.Reader) {
+ m.attachments = append(m.attachments, attachment{
+ filename: name,
+ content: r,
+ inline: true,
+ })
+}
+
+// AttachInlineWithMimeType adds the contents of r to the email as an inline attachment
+// with mimeType as the specified MIME type of the content. Inline attachments are
+// typically used within the email body, such as a logo or header image. It is up to the
+// user to ensure name is unique and the specified mimeType is correct.
+//
+// Files can be referenced by their name within the email using the cid URL
+// protocol:
+//
+//
+//
+// r is not read until Send is called.
+func (m *MailYak) AttachInlineWithMimeType(name string, r io.Reader, mimeType string) {
+ m.attachments = append(m.attachments, attachment{
+ filename: name,
+ content: r,
+ inline: true,
+ mimeType: mimeType,
+ })
+}
+
+// ClearAttachments removes all current attachments.
+func (m *MailYak) ClearAttachments() {
+ m.attachments = []attachment{}
+}
+
+// writeAttachments loops over the attachments, guesses their content-type and
+// writes the data as a line-broken base64 string (using the splitter mutator).
+func (m *MailYak) writeAttachments(mixed partCreator, splitter writeWrapper) error {
+ h := make([]byte, sniffLen)
+
+ for _, item := range m.attachments {
+ hLen, err := io.ReadFull(item.content, h)
+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
+ return err
+ }
+
+ if item.mimeType == "" {
+ item.mimeType = http.DetectContentType(h[:hLen])
+ }
+
+ ctype := fmt.Sprintf("%s;\n\tfilename=%q; name=%q", item.mimeType, item.filename, item.filename)
+
+ part, err := mixed.CreatePart(getMIMEHeader(item, ctype))
+ if err != nil {
+ return err
+ }
+
+ encoder := base64.NewEncoder(base64.StdEncoding, splitter.new(part))
+ if _, err := encoder.Write(h[:hLen]); err != nil {
+ return err
+ }
+
+ // More to write?
+ if hLen == sniffLen {
+ if _, err := io.Copy(encoder, item.content); err != nil {
+ return err
+ }
+ }
+
+ if err := encoder.Close(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func getMIMEHeader(a attachment, ctype string) textproto.MIMEHeader {
+ var disp string
+ var header textproto.MIMEHeader
+
+ cid := fmt.Sprintf("<%s>", a.filename)
+ if a.inline {
+ disp = fmt.Sprintf("inline;\n\tfilename=%q; name=%q", a.filename, a.filename)
+ header = textproto.MIMEHeader{
+ "Content-Type": {ctype},
+ "Content-Disposition": {disp},
+ "Content-Transfer-Encoding": {"base64"},
+ "Content-ID": {cid},
+ }
+ } else {
+ disp = fmt.Sprintf("attachment;\n\tfilename=%q; name=%q", a.filename, a.filename)
+ header = textproto.MIMEHeader{
+ "Content-Type": {ctype},
+ "Content-Disposition": {disp},
+ "Content-Transfer-Encoding": {"base64"},
+ "Content-ID": {cid},
+ }
+ }
+
+ return header
+}
diff --git a/attachments_test.go b/attachments_test.go
new file mode 100644
index 0000000..326c2ea
--- /dev/null
+++ b/attachments_test.go
@@ -0,0 +1,1025 @@
+package mailyak
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+ "net/textproto"
+ "strings"
+ "testing"
+)
+
+type testAttachment struct {
+ contentType string
+ disposition string
+ data bytes.Buffer
+}
+
+// Satisfy the partCreator interface and keep track of wrote attachments
+type testPartCreator struct {
+ attachments []*testAttachment
+}
+
+func (t *testPartCreator) CreatePart(header textproto.MIMEHeader) (io.Writer, error) {
+ a := &testAttachment{
+ contentType: header.Get("Content-Type"),
+ disposition: header.Get("Content-Disposition"),
+ }
+
+ t.attachments = append(t.attachments, a)
+ return &a.data, nil
+}
+
+// nopSplitter - it does nothing!
+type nopSplitter struct {
+ w io.Writer
+}
+
+func (t nopSplitter) Write(p []byte) (int, error) {
+ return t.w.Write(p)
+}
+
+// nopBuilder satisfies the writeWrapper interface
+type nopBuilder struct{}
+
+func (b nopBuilder) new(w io.Writer) io.Writer {
+ return &nopSplitter{w: w}
+}
+
+// TestMailYakAttach calls Attach() and ensures the attachment slice is the
+// correct length
+func TestMailYakAttach(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rattachments []attachment
+ // Parameters.
+ pname string
+ r io.Reader
+ // Expect
+ count int
+ }{
+ {
+ "From empty",
+ []attachment{},
+ "test",
+ &bytes.Buffer{},
+ 1,
+ },
+ {
+ "From one",
+ []attachment{{"Existing", &bytes.Buffer{}, false, ""}},
+ "test",
+ &bytes.Buffer{},
+ 2,
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{attachments: tt.rattachments}
+ m.Attach(tt.pname, tt.r)
+
+ if tt.count != len(m.attachments) {
+ t.Errorf("%q. MailYak.Attach() len = %v, wantLen %v", tt.name, len(m.attachments), tt.count)
+ }
+ })
+ }
+}
+
+// TestMailYakAttach calls AttachInline() and ensures the attachment slice is the
+// correct length
+func TestMailYakAttachInline(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rattachments []attachment
+ // Parameters.
+ pname string
+ r io.Reader
+ // Expect
+ count int
+ }{
+ {
+ "From empty",
+ []attachment{},
+ "test",
+ &bytes.Buffer{},
+ 1,
+ },
+ {
+ "From one",
+ []attachment{{"Existing", &bytes.Buffer{}, false, ""}},
+ "test",
+ &bytes.Buffer{},
+ 2,
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{attachments: tt.rattachments}
+ m.AttachInline(tt.pname, tt.r)
+
+ if tt.count != len(m.attachments) {
+ t.Errorf("%q. MailYak.Attach() len = %v, wantLen %v", tt.name, len(m.attachments), tt.count)
+ }
+ })
+ }
+}
+
+// TestMailYakAttachWithMimeType calls AttachWithMimeType() and ensures the attachment slice is the
+// correct length
+func TestMailYakAttachWithMimeType(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rattachments []attachment
+ // Parameters.
+ pname string
+ r io.Reader
+ mime string
+ // Expect
+ count int
+ }{
+ {
+ "From empty",
+ []attachment{},
+ "test",
+ &bytes.Buffer{},
+ "text/csv; charset=utf-8",
+ 1,
+ },
+ {
+ "From one",
+ []attachment{{"Existing", &bytes.Buffer{}, false, "text/csv; charset=utf-8"}},
+ "test",
+ &bytes.Buffer{},
+ "text/csv; charset=utf-8",
+ 2,
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{attachments: tt.rattachments}
+ m.AttachWithMimeType(tt.pname, tt.r, tt.mime)
+
+ if tt.count != len(m.attachments) {
+ t.Errorf("%q. MailYak.Attach() len = %v, wantLen %v", tt.name, len(m.attachments), tt.count)
+ }
+ })
+ }
+}
+
+// TestMailYakAttachWithMimeType calls AttachInlineWithMimeType() and ensures the attachment slice is the
+// correct length
+func TestMailYakAttachInlineWithMimeType(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rattachments []attachment
+ // Parameters.
+ pname string
+ r io.Reader
+ mime string
+ // Expect
+ count int
+ }{
+ {
+ "From empty",
+ []attachment{},
+ "test",
+ &bytes.Buffer{},
+ "text/csv; charset=utf-8",
+ 1,
+ },
+ {
+ "From one",
+ []attachment{{"Existing", &bytes.Buffer{}, false, "text/csv; charset=utf-8"}},
+ "test",
+ &bytes.Buffer{},
+ "text/csv; charset=utf-8",
+ 2,
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{attachments: tt.rattachments}
+ m.AttachInlineWithMimeType(tt.pname, tt.r, tt.mime)
+
+ if tt.count != len(m.attachments) {
+ t.Errorf("%q. MailYak.Attach() len = %v, wantLen %v", tt.name, len(m.attachments), tt.count)
+ }
+ })
+ }
+}
+
+// TestMailYakWriteAttachments ensures the correct headers are wrote, and the
+// data is base64 encoded correctly
+func TestMailYakWriteAttachments(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rattachments []attachment
+ // Expected results.
+ ctype string
+ disp string
+ data string
+ wantErr bool
+ }{
+ {
+ "Empty",
+ []attachment{{"Empty", &bytes.Buffer{}, false, ""}},
+ "text/plain; charset=utf-8;\n\tfilename=\"Empty\"; name=\"Empty\"",
+ "attachment;\n\tfilename=\"Empty\"; name=\"Empty\"",
+ "",
+ false,
+ },
+ {
+ "Short string",
+ []attachment{{"advice", strings.NewReader("Don't Panic"), false, ""}},
+ "text/plain; charset=utf-8;\n\tfilename=\"advice\"; name=\"advice\"",
+ "attachment;\n\tfilename=\"advice\"; name=\"advice\"",
+ "RG9uJ3QgUGFuaWM=",
+ false,
+ },
+ {
+ "Space in filename",
+ []attachment{{"Empty with spaces", &bytes.Buffer{}, false, ""}},
+ "text/plain; charset=utf-8;\n\tfilename=\"Empty with spaces\"; name=\"Empty with spaces\"",
+ "attachment;\n\tfilename=\"Empty with spaces\"; name=\"Empty with spaces\"",
+ "",
+ false,
+ },
+ {
+ "With specified MIME type",
+ []attachment{{"Empty with spaces", &bytes.Buffer{}, false, "text/csv; charset=utf-8"}},
+ "text/csv; charset=utf-8;\n\tfilename=\"Empty with spaces\"; name=\"Empty with spaces\"",
+ "attachment;\n\tfilename=\"Empty with spaces\"; name=\"Empty with spaces\"",
+ "",
+ false,
+ },
+ {
+ "Longer string",
+ []attachment{
+ {
+ "partyinvite.txt",
+ strings.NewReader(
+ "If Baldrick served a meal at HQ he would be arrested for the biggest " +
+ "mass poisoning since Lucretia Borgia invited 500 friends for a Wine and Anthrax Party.",
+ ),
+ false,
+ "",
+ },
+ },
+ "text/plain; charset=utf-8;\n\tfilename=\"partyinvite.txt\"; name=\"partyinvite.txt\"",
+ "attachment;\n\tfilename=\"partyinvite.txt\"; name=\"partyinvite.txt\"",
+ "SWYgQmFsZHJpY2sgc2VydmVkIGEgbWVhbCBhdCBIUSBoZSB3b3VsZCBiZSBhcnJlc3Rl" +
+ "ZCBmb3IgdGhlIGJpZ2dlc3QgbWFzcyBwb2lzb25pbmcgc2luY2UgTHVjcmV0aWEgQm9y" +
+ "Z2lhIGludml0ZWQgNTAwIGZyaWVuZHMgZm9yIGEgV2luZSBhbmQgQW50aHJheCBQYXJ0eS4=",
+ false,
+ },
+ {
+ "String >512 characters (content type sniff)",
+ []attachment{
+ {
+ "qed.txt",
+ strings.NewReader(
+ `Now it is such a bizarrely improbable coincidence that anything so mind-bogglingly ` +
+ `useful could have evolved purely by chance that some thinkers have chosen to see it ` +
+ `as the final and clinching proof of the non-existence of God. The argument goes something ` +
+ `like this: "I refuse to prove that I exist," says God, "for proof denies faith, and ` +
+ `without faith I am nothing." "But," says Man, "The Babel fish is a dead giveaway, ` +
+ `isn't it? It could not have evolved by chance. It proves you exist, and so therefore, ` +
+ `by your own arguments, you don't. QED." "Oh dear," says God, "I hadn't thought of ` +
+ `that," and promptly vanishes in a puff of logic. "Oh, that was easy," says Man, and ` +
+ `for an encore goes on to prove that black is white and gets himself killed on the next ` +
+ `zebra crossing.`,
+ ),
+ false,
+ "",
+ },
+ },
+ "text/plain; charset=utf-8;\n\tfilename=\"qed.txt\"; name=\"qed.txt\"",
+ "attachment;\n\tfilename=\"qed.txt\"; name=\"qed.txt\"",
+ "Tm93IGl0IGlzIHN1Y2ggYSBiaXphcnJlbHkgaW1wcm9iYWJsZSBjb2luY2lkZW5jZSB0a" +
+ "GF0IGFueXRoaW5nIHNvIG1pbmQtYm9nZ2xpbmdseSB1c2VmdWwgY291bGQgaGF2ZSBldm" +
+ "9sdmVkIHB1cmVseSBieSBjaGFuY2UgdGhhdCBzb21lIHRoaW5rZXJzIGhhdmUgY2hvc2V" +
+ "uIHRvIHNlZSBpdCBhcyB0aGUgZmluYWwgYW5kIGNsaW5jaGluZyBwcm9vZiBvZiB0aGUg" +
+ "bm9uLWV4aXN0ZW5jZSBvZiBHb2QuIFRoZSBhcmd1bWVudCBnb2VzIHNvbWV0aGluZyBsa" +
+ "WtlIHRoaXM6ICJJIHJlZnVzZSB0byBwcm92ZSB0aGF0IEkgZXhpc3QsIiBzYXlzIEdvZC" +
+ "wgImZvciBwcm9vZiBkZW5pZXMgZmFpdGgsIGFuZCB3aXRob3V0IGZhaXRoIEkgYW0gbm9" +
+ "0aGluZy4iICJCdXQsIiBzYXlzIE1hbiwgIlRoZSBCYWJlbCBmaXNoIGlzIGEgZGVhZCBn" +
+ "aXZlYXdheSwgaXNuJ3QgaXQ/IEl0IGNvdWxkIG5vdCBoYXZlIGV2b2x2ZWQgYnkgY2hhb" +
+ "mNlLiBJdCBwcm92ZXMgeW91IGV4aXN0LCBhbmQgc28gdGhlcmVmb3JlLCBieSB5b3VyIG" +
+ "93biBhcmd1bWVudHMsIHlvdSBkb24ndC4gUUVELiIgIk9oIGRlYXIsIiBzYXlzIEdvZCw" +
+ "gIkkgaGFkbid0IHRob3VnaHQgb2YgdGhhdCwiIGFuZCBwcm9tcHRseSB2YW5pc2hlcyBp" +
+ "biBhIHB1ZmYgb2YgbG9naWMuICJPaCwgdGhhdCB3YXMgZWFzeSwiIHNheXMgTWFuLCBhb" +
+ "mQgZm9yIGFuIGVuY29yZSBnb2VzIG9uIHRvIHByb3ZlIHRoYXQgYmxhY2sgaXMgd2hpdG" +
+ "UgYW5kIGdldHMgaGltc2VsZiBraWxsZWQgb24gdGhlIG5leHQgemVicmEgY3Jvc3Npbmcu",
+ false,
+ },
+ {
+ "HTML",
+ []attachment{{"name.html", strings.NewReader(""), false, ""}},
+ "text/html; charset=utf-8;\n\tfilename=\"name.html\"; name=\"name.html\"",
+ "attachment;\n\tfilename=\"name.html\"; name=\"name.html\"",
+ "PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4=",
+ false,
+ },
+ {
+ "HTML - wrong extension",
+ []attachment{{"name.png", strings.NewReader(""), false, ""}},
+ "text/html; charset=utf-8;\n\tfilename=\"name.png\"; name=\"name.png\"",
+ "attachment;\n\tfilename=\"name.png\"; name=\"name.png\"",
+ "PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4=",
+ false,
+ },
+
+ // inline attachments
+ {
+ "Empty inline",
+ []attachment{{"Empty", &bytes.Buffer{}, true, ""}},
+ "text/plain; charset=utf-8;\n\tfilename=\"Empty\"; name=\"Empty\"",
+ "inline;\n\tfilename=\"Empty\"; name=\"Empty\"",
+ "",
+ false,
+ },
+ {
+ "Short string inline",
+ []attachment{{"advice", strings.NewReader("Don't Panic"), true, ""}},
+ "text/plain; charset=utf-8;\n\tfilename=\"advice\"; name=\"advice\"",
+ "inline;\n\tfilename=\"advice\"; name=\"advice\"",
+ "RG9uJ3QgUGFuaWM=",
+ false,
+ },
+ {
+ "Longer string inline",
+ []attachment{
+ {
+ "partyinvite.txt",
+ strings.NewReader(
+ "If Baldrick served a meal at HQ he would be arrested for the biggest " +
+ "mass poisoning since Lucretia Borgia invited 500 friends for a Wine and Anthrax Party.",
+ ),
+ true,
+ "",
+ },
+ },
+ "text/plain; charset=utf-8;\n\tfilename=\"partyinvite.txt\"; name=\"partyinvite.txt\"",
+ "inline;\n\tfilename=\"partyinvite.txt\"; name=\"partyinvite.txt\"",
+ "SWYgQmFsZHJpY2sgc2VydmVkIGEgbWVhbCBhdCBIUSBoZSB3b3VsZCBiZSBhcnJlc3Rl" +
+ "ZCBmb3IgdGhlIGJpZ2dlc3QgbWFzcyBwb2lzb25pbmcgc2luY2UgTHVjcmV0aWEgQm9y" +
+ "Z2lhIGludml0ZWQgNTAwIGZyaWVuZHMgZm9yIGEgV2luZSBhbmQgQW50aHJheCBQYXJ0eS4=",
+ false,
+ },
+ {
+ "String >512 characters (content type sniff) inline",
+ []attachment{
+ {
+ "qed.txt",
+ strings.NewReader(
+ `Now it is such a bizarrely improbable coincidence that anything so mind-bogglingly ` +
+ `useful could have evolved purely by chance that some thinkers have chosen to see it ` +
+ `as the final and clinching proof of the non-existence of God. The argument goes something ` +
+ `like this: "I refuse to prove that I exist," says God, "for proof denies faith, and ` +
+ `without faith I am nothing." "But," says Man, "The Babel fish is a dead giveaway, ` +
+ `isn't it? It could not have evolved by chance. It proves you exist, and so therefore, ` +
+ `by your own arguments, you don't. QED." "Oh dear," says God, "I hadn't thought of ` +
+ `that," and promptly vanishes in a puff of logic. "Oh, that was easy," says Man, and ` +
+ `for an encore goes on to prove that black is white and gets himself killed on the next ` +
+ `zebra crossing.`,
+ ),
+ true,
+ "",
+ },
+ },
+ "text/plain; charset=utf-8;\n\tfilename=\"qed.txt\"; name=\"qed.txt\"",
+ "inline;\n\tfilename=\"qed.txt\"; name=\"qed.txt\"",
+ "Tm93IGl0IGlzIHN1Y2ggYSBiaXphcnJlbHkgaW1wcm9iYWJsZSBjb2luY2lkZW5jZSB0a" +
+ "GF0IGFueXRoaW5nIHNvIG1pbmQtYm9nZ2xpbmdseSB1c2VmdWwgY291bGQgaGF2ZSBldm" +
+ "9sdmVkIHB1cmVseSBieSBjaGFuY2UgdGhhdCBzb21lIHRoaW5rZXJzIGhhdmUgY2hvc2V" +
+ "uIHRvIHNlZSBpdCBhcyB0aGUgZmluYWwgYW5kIGNsaW5jaGluZyBwcm9vZiBvZiB0aGUg" +
+ "bm9uLWV4aXN0ZW5jZSBvZiBHb2QuIFRoZSBhcmd1bWVudCBnb2VzIHNvbWV0aGluZyBsa" +
+ "WtlIHRoaXM6ICJJIHJlZnVzZSB0byBwcm92ZSB0aGF0IEkgZXhpc3QsIiBzYXlzIEdvZC" +
+ "wgImZvciBwcm9vZiBkZW5pZXMgZmFpdGgsIGFuZCB3aXRob3V0IGZhaXRoIEkgYW0gbm9" +
+ "0aGluZy4iICJCdXQsIiBzYXlzIE1hbiwgIlRoZSBCYWJlbCBmaXNoIGlzIGEgZGVhZCBn" +
+ "aXZlYXdheSwgaXNuJ3QgaXQ/IEl0IGNvdWxkIG5vdCBoYXZlIGV2b2x2ZWQgYnkgY2hhb" +
+ "mNlLiBJdCBwcm92ZXMgeW91IGV4aXN0LCBhbmQgc28gdGhlcmVmb3JlLCBieSB5b3VyIG" +
+ "93biBhcmd1bWVudHMsIHlvdSBkb24ndC4gUUVELiIgIk9oIGRlYXIsIiBzYXlzIEdvZCw" +
+ "gIkkgaGFkbid0IHRob3VnaHQgb2YgdGhhdCwiIGFuZCBwcm9tcHRseSB2YW5pc2hlcyBp" +
+ "biBhIHB1ZmYgb2YgbG9naWMuICJPaCwgdGhhdCB3YXMgZWFzeSwiIHNheXMgTWFuLCBhb" +
+ "mQgZm9yIGFuIGVuY29yZSBnb2VzIG9uIHRvIHByb3ZlIHRoYXQgYmxhY2sgaXMgd2hpdG" +
+ "UgYW5kIGdldHMgaGltc2VsZiBraWxsZWQgb24gdGhlIG5leHQgemVicmEgY3Jvc3Npbmcu",
+ false,
+ },
+ {
+ "HTML inline",
+ []attachment{{"name.html", strings.NewReader(""), true, ""}},
+ "text/html; charset=utf-8;\n\tfilename=\"name.html\"; name=\"name.html\"",
+ "inline;\n\tfilename=\"name.html\"; name=\"name.html\"",
+ "PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4=",
+ false,
+ },
+ {
+ "HTML - wrong extension inline",
+ []attachment{{"name.png", strings.NewReader(""), true, ""}},
+ "text/html; charset=utf-8;\n\tfilename=\"name.png\"; name=\"name.png\"",
+ "inline;\n\tfilename=\"name.png\"; name=\"name.png\"",
+ "PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4=",
+ false,
+ },
+ {
+ "String >512 characters (read full buffer)",
+ []attachment{
+ {
+ "qed.txt",
+ base64.NewDecoder(base64.StdEncoding, strings.NewReader(
+ "Tm93IGl0IGlzIHN1Y2ggYSBiaXphcnJlbHkgaW1wcm9iYWJsZSBjb2luY2lkZW5jZSB0a"+
+ "GF0IGFueXRoaW5nIHNvIG1pbmQtYm9nZ2xpbmdseSB1c2VmdWwgY291bGQgaGF2ZSBldm"+
+ "9sdmVkIHB1cmVseSBieSBjaGFuY2UgdGhhdCBzb21lIHRoaW5rZXJzIGhhdmUgY2hvc2V"+
+ "uIHRvIHNlZSBpdCBhcyB0aGUgZmluYWwgYW5kIGNsaW5jaGluZyBwcm9vZiBvZiB0aGUg"+
+ "bm9uLWV4aXN0ZW5jZSBvZiBHb2QuIFRoZSBhcmd1bWVudCBnb2VzIHNvbWV0aGluZyBsa"+
+ "WtlIHRoaXM6ICJJIHJlZnVzZSB0byBwcm92ZSB0aGF0IEkgZXhpc3QsIiBzYXlzIEdvZC"+
+ "wgImZvciBwcm9vZiBkZW5pZXMgZmFpdGgsIGFuZCB3aXRob3V0IGZhaXRoIEkgYW0gbm9"+
+ "0aGluZy4iICJCdXQsIiBzYXlzIE1hbiwgIlRoZSBCYWJlbCBmaXNoIGlzIGEgZGVhZCBn"+
+ "aXZlYXdheSwgaXNuJ3QgaXQ/IEl0IGNvdWxkIG5vdCBoYXZlIGV2b2x2ZWQgYnkgY2hhb"+
+ "mNlLiBJdCBwcm92ZXMgeW91IGV4aXN0LCBhbmQgc28gdGhlcmVmb3JlLCBieSB5b3VyIG"+
+ "93biBhcmd1bWVudHMsIHlvdSBkb24ndC4gUUVELiIgIk9oIGRlYXIsIiBzYXlzIEdvZCw"+
+ "gIkkgaGFkbid0IHRob3VnaHQgb2YgdGhhdCwiIGFuZCBwcm9tcHRseSB2YW5pc2hlcyBp"+
+ "biBhIHB1ZmYgb2YgbG9naWMuICJPaCwgdGhhdCB3YXMgZWFzeSwiIHNheXMgTWFuLCBhb"+
+ "mQgZm9yIGFuIGVuY29yZSBnb2VzIG9uIHRvIHByb3ZlIHRoYXQgYmxhY2sgaXMgd2hpdG"+
+ "UgYW5kIGdldHMgaGltc2VsZiBraWxsZWQgb24gdGhlIG5leHQgemVicmEgY3Jvc3Npbmcu",
+ )),
+ false,
+ "",
+ },
+ },
+ "text/plain; charset=utf-8;\n\tfilename=\"qed.txt\"; name=\"qed.txt\"",
+ "attachment;\n\tfilename=\"qed.txt\"; name=\"qed.txt\"",
+ "Tm93IGl0IGlzIHN1Y2ggYSBiaXphcnJlbHkgaW1wcm9iYWJsZSBjb2luY2lkZW5jZSB0a" +
+ "GF0IGFueXRoaW5nIHNvIG1pbmQtYm9nZ2xpbmdseSB1c2VmdWwgY291bGQgaGF2ZSBldm" +
+ "9sdmVkIHB1cmVseSBieSBjaGFuY2UgdGhhdCBzb21lIHRoaW5rZXJzIGhhdmUgY2hvc2V" +
+ "uIHRvIHNlZSBpdCBhcyB0aGUgZmluYWwgYW5kIGNsaW5jaGluZyBwcm9vZiBvZiB0aGUg" +
+ "bm9uLWV4aXN0ZW5jZSBvZiBHb2QuIFRoZSBhcmd1bWVudCBnb2VzIHNvbWV0aGluZyBsa" +
+ "WtlIHRoaXM6ICJJIHJlZnVzZSB0byBwcm92ZSB0aGF0IEkgZXhpc3QsIiBzYXlzIEdvZC" +
+ "wgImZvciBwcm9vZiBkZW5pZXMgZmFpdGgsIGFuZCB3aXRob3V0IGZhaXRoIEkgYW0gbm9" +
+ "0aGluZy4iICJCdXQsIiBzYXlzIE1hbiwgIlRoZSBCYWJlbCBmaXNoIGlzIGEgZGVhZCBn" +
+ "aXZlYXdheSwgaXNuJ3QgaXQ/IEl0IGNvdWxkIG5vdCBoYXZlIGV2b2x2ZWQgYnkgY2hhb" +
+ "mNlLiBJdCBwcm92ZXMgeW91IGV4aXN0LCBhbmQgc28gdGhlcmVmb3JlLCBieSB5b3VyIG" +
+ "93biBhcmd1bWVudHMsIHlvdSBkb24ndC4gUUVELiIgIk9oIGRlYXIsIiBzYXlzIEdvZCw" +
+ "gIkkgaGFkbid0IHRob3VnaHQgb2YgdGhhdCwiIGFuZCBwcm9tcHRseSB2YW5pc2hlcyBp" +
+ "biBhIHB1ZmYgb2YgbG9naWMuICJPaCwgdGhhdCB3YXMgZWFzeSwiIHNheXMgTWFuLCBhb" +
+ "mQgZm9yIGFuIGVuY29yZSBnb2VzIG9uIHRvIHByb3ZlIHRoYXQgYmxhY2sgaXMgd2hpdG" +
+ "UgYW5kIGdldHMgaGltc2VsZiBraWxsZWQgb24gdGhlIG5leHQgemVicmEgY3Jvc3Npbmcu",
+ false,
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := MailYak{attachments: tt.rattachments}
+ pc := testPartCreator{}
+
+ if err := m.writeAttachments(&pc, nopBuilder{}); (err != nil) != tt.wantErr {
+ t.Errorf("%q. MailYak.writeAttachments() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ // Ensure there's an attachment
+ if len(pc.attachments) != 1 {
+ t.Fatalf("%q. MailYak.writeAttachments() unexpected number of attachments = %v, want 1", tt.name, len(pc.attachments))
+ }
+
+ if pc.attachments[0].contentType != tt.ctype {
+ t.Errorf("%q. MailYak.writeAttachments() content type = %v, want %v", tt.name, pc.attachments[0].contentType, tt.ctype)
+ }
+
+ if pc.attachments[0].disposition != tt.disp {
+ t.Errorf("%q. MailYak.writeAttachments() disposition = %v, want %v", tt.name, pc.attachments[0].disposition, tt.disp)
+ }
+
+ if pc.attachments[0].data.String() != tt.data {
+ t.Errorf("%q. MailYak.writeAttachments() data = %v, want %v", tt.name, pc.attachments[0].data.String(), tt.data)
+ }
+ })
+ }
+}
+
+// TestMailYakWriteAttachments_multipleAttachments ensures multiple attachments
+// are correctly handled
+func TestMailYakWriteAttachments_multipleAttachments(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rattachments []attachment
+ // Expected results.
+ want []testAttachment
+ wantErr bool
+ }{
+ {
+ "Single Attachment",
+ []attachment{{"name.txt", strings.NewReader("test"), false, ""}},
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "attachment;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ },
+ false,
+ },
+ {
+ "Single Attachment with specified MIME type",
+ []attachment{{"name.txt", strings.NewReader("test"), false, "text/csv; charset=utf-8"}},
+ []testAttachment{
+ {
+ contentType: "text/csv; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "attachment;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Attachment - same types",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), false, ""},
+ {"different.txt", strings.NewReader("another"), false, ""},
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "attachment;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ disposition: "attachment;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ data: *bytes.NewBufferString("YW5vdGhlcg=="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Attachment - different types",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), false, ""},
+ {"html.txt", strings.NewReader(""), false, ""},
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "attachment;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "text/html; charset=utf-8;\n\tfilename=\"html.txt\"; name=\"html.txt\"",
+ disposition: "attachment;\n\tfilename=\"html.txt\"; name=\"html.txt\"",
+ data: *bytes.NewBufferString("PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Attachment - different specified MIME types",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), false, "text/csv; charset=utf-8"},
+ {"html.txt", strings.NewReader(""), false, "application/xml"},
+ },
+ []testAttachment{
+ {
+ contentType: "text/csv; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "attachment;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "application/xml;\n\tfilename=\"html.txt\"; name=\"html.txt\"",
+ disposition: "attachment;\n\tfilename=\"html.txt\"; name=\"html.txt\"",
+ data: *bytes.NewBufferString("PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Attachments - >512 bytes, longer first",
+ []attachment{
+ {
+ "550.txt",
+ strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris ut nisl felis. " +
+ "Aenean felis justo, gravida eget leo aliquet, molestie aliquam risus. Vestibulum " +
+ "et nibh rhoncus, malesuada tellus eget, pellentesque diam. Sed venenatis vitae " +
+ "erat vel ullamcorper. Aenean rutrum pulvinar purus eget cursus. Integer at iaculis " +
+ "arcu. Maecenas mollis nulla dolor, et ultricies massa posuere quis. Nulla facilisi. " +
+ "Proin luctus nec nisl at imperdiet. Nulla dapibus purus ut lorem faucibus, at gravida " +
+ "tellus euismod. Curabitur ex risus, egestas in porta amet.",
+ ),
+ false,
+ "",
+ },
+ {
+ "520.txt", strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu vestibulum dolor. " +
+ "Nunc ac posuere felis, a mattis leo. Duis elementum tempor leo, sed efficitur nunc. " +
+ "Cras ornare feugiat vulputate. Maecenas sit amet felis lobortis ipsum dignissim euismod. " +
+ "Vestibulum id ullamcorper nulla, tincidunt hendrerit justo. Donec vitae eros quam. Nulla " +
+ "accumsan porta sapien, in consequat mauris fermentum ac. In at sem lobortis, auctor metus " +
+ "rutrum, blandit ipsum. Praesent commodo porta semper. Etiam dignissim libero nullam.",
+ ),
+ false,
+ "",
+ },
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ disposition: "attachment;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gTWF1cmlzIHV0IG5pc" +
+ "2wgZmVsaXMuIEFlbmVhbiBmZWxpcyBqdXN0bywgZ3JhdmlkYSBlZ2V0IGxlbyBhbGlxdWV0LCBtb2xlc3RpZSBhbGlxdW" +
+ "FtIHJpc3VzLiBWZXN0aWJ1bHVtIGV0IG5pYmggcmhvbmN1cywgbWFsZXN1YWRhIHRlbGx1cyBlZ2V0LCBwZWxsZW50ZXN" +
+ "xdWUgZGlhbS4gU2VkIHZlbmVuYXRpcyB2aXRhZSBlcmF0IHZlbCB1bGxhbWNvcnBlci4gQWVuZWFuIHJ1dHJ1bSBwdWx2" +
+ "aW5hciBwdXJ1cyBlZ2V0IGN1cnN1cy4gSW50ZWdlciBhdCBpYWN1bGlzIGFyY3UuIE1hZWNlbmFzIG1vbGxpcyBudWxsY" +
+ "SBkb2xvciwgZXQgdWx0cmljaWVzIG1hc3NhIHBvc3VlcmUgcXVpcy4gTnVsbGEgZmFjaWxpc2kuIFByb2luIGx1Y3R1cy" +
+ "BuZWMgbmlzbCBhdCBpbXBlcmRpZXQuIE51bGxhIGRhcGlidXMgcHVydXMgdXQgbG9yZW0gZmF1Y2lidXMsIGF0IGdyYXZ" +
+ "pZGEgdGVsbHVzIGV1aXNtb2QuIEN1cmFiaXR1ciBleCByaXN1cywgZWdlc3RhcyBpbiBwb3J0YSBhbWV0Lg==",
+ ),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ disposition: "attachment;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gRG9uZWMgZXUgdmVz" +
+ "dGlidWx1bSBkb2xvci4gTnVuYyBhYyBwb3N1ZXJlIGZlbGlzLCBhIG1hdHRpcyBsZW8uIER1aXMgZWxlbWVudHVtIHRl" +
+ "bXBvciBsZW8sIHNlZCBlZmZpY2l0dXIgbnVuYy4gQ3JhcyBvcm5hcmUgZmV1Z2lhdCB2dWxwdXRhdGUuIE1hZWNlbmFz" +
+ "IHNpdCBhbWV0IGZlbGlzIGxvYm9ydGlzIGlwc3VtIGRpZ25pc3NpbSBldWlzbW9kLiBWZXN0aWJ1bHVtIGlkIHVsbGFt" +
+ "Y29ycGVyIG51bGxhLCB0aW5jaWR1bnQgaGVuZHJlcml0IGp1c3RvLiBEb25lYyB2aXRhZSBlcm9zIHF1YW0uIE51bGxh" +
+ "IGFjY3Vtc2FuIHBvcnRhIHNhcGllbiwgaW4gY29uc2VxdWF0IG1hdXJpcyBmZXJtZW50dW0gYWMuIEluIGF0IHNlbSBs" +
+ "b2JvcnRpcywgYXVjdG9yIG1ldHVzIHJ1dHJ1bSwgYmxhbmRpdCBpcHN1bS4gUHJhZXNlbnQgY29tbW9kbyBwb3J0YSBz" +
+ "ZW1wZXIuIEV0aWFtIGRpZ25pc3NpbSBsaWJlcm8gbnVsbGFtLg==",
+ ),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Attachments - >512 bytes, shorter first",
+ []attachment{
+ {
+ "520.txt",
+ strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu vestibulum dolor. Nunc ac " +
+ "posuere felis, a mattis leo. Duis elementum tempor leo, sed efficitur nunc. Cras ornare " +
+ "feugiat vulputate. Maecenas sit amet felis lobortis ipsum dignissim euismod. Vestibulum " +
+ "id ullamcorper nulla, tincidunt hendrerit justo. Donec vitae eros quam. Nulla accumsan " +
+ "porta sapien, in consequat mauris fermentum ac. In at sem lobortis, auctor metus rutrum, " +
+ "blandit ipsum. Praesent commodo porta semper. Etiam dignissim libero nullam.",
+ ),
+ false,
+ "",
+ },
+ {
+ "550.txt",
+ strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris ut nisl felis. Aenean felis " +
+ "justo, gravida eget leo aliquet, molestie aliquam risus. Vestibulum et nibh rhoncus, " +
+ "malesuada tellus eget, pellentesque diam. Sed venenatis vitae erat vel ullamcorper. " +
+ "Aenean rutrum pulvinar purus eget cursus. Integer at iaculis arcu. Maecenas mollis " +
+ "nulla dolor, et ultricies massa posuere quis. Nulla facilisi. Proin luctus nec nisl " +
+ "at imperdiet. Nulla dapibus purus ut lorem faucibus, at gravida tellus euismod. Curabitur " +
+ "ex risus, egestas in porta amet.",
+ ),
+ false,
+ "",
+ },
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ disposition: "attachment;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gRG9uZWMgZXUgdmVz" +
+ "dGlidWx1bSBkb2xvci4gTnVuYyBhYyBwb3N1ZXJlIGZlbGlzLCBhIG1hdHRpcyBsZW8uIER1aXMgZWxlbWVudHVtIHRl" +
+ "bXBvciBsZW8sIHNlZCBlZmZpY2l0dXIgbnVuYy4gQ3JhcyBvcm5hcmUgZmV1Z2lhdCB2dWxwdXRhdGUuIE1hZWNlbmFz" +
+ "IHNpdCBhbWV0IGZlbGlzIGxvYm9ydGlzIGlwc3VtIGRpZ25pc3NpbSBldWlzbW9kLiBWZXN0aWJ1bHVtIGlkIHVsbGFt" +
+ "Y29ycGVyIG51bGxhLCB0aW5jaWR1bnQgaGVuZHJlcml0IGp1c3RvLiBEb25lYyB2aXRhZSBlcm9zIHF1YW0uIE51bGxh" +
+ "IGFjY3Vtc2FuIHBvcnRhIHNhcGllbiwgaW4gY29uc2VxdWF0IG1hdXJpcyBmZXJtZW50dW0gYWMuIEluIGF0IHNlbSBs" +
+ "b2JvcnRpcywgYXVjdG9yIG1ldHVzIHJ1dHJ1bSwgYmxhbmRpdCBpcHN1bS4gUHJhZXNlbnQgY29tbW9kbyBwb3J0YSBz" +
+ "ZW1wZXIuIEV0aWFtIGRpZ25pc3NpbSBsaWJlcm8gbnVsbGFtLg==",
+ ),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ disposition: "attachment;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gTWF1cmlzIHV0IG5p" +
+ "c2wgZmVsaXMuIEFlbmVhbiBmZWxpcyBqdXN0bywgZ3JhdmlkYSBlZ2V0IGxlbyBhbGlxdWV0LCBtb2xlc3RpZSBhbGlx" +
+ "dWFtIHJpc3VzLiBWZXN0aWJ1bHVtIGV0IG5pYmggcmhvbmN1cywgbWFsZXN1YWRhIHRlbGx1cyBlZ2V0LCBwZWxsZW50" +
+ "ZXNxdWUgZGlhbS4gU2VkIHZlbmVuYXRpcyB2aXRhZSBlcmF0IHZlbCB1bGxhbWNvcnBlci4gQWVuZWFuIHJ1dHJ1bSBw" +
+ "dWx2aW5hciBwdXJ1cyBlZ2V0IGN1cnN1cy4gSW50ZWdlciBhdCBpYWN1bGlzIGFyY3UuIE1hZWNlbmFzIG1vbGxpcyBu" +
+ "dWxsYSBkb2xvciwgZXQgdWx0cmljaWVzIG1hc3NhIHBvc3VlcmUgcXVpcy4gTnVsbGEgZmFjaWxpc2kuIFByb2luIGx1" +
+ "Y3R1cyBuZWMgbmlzbCBhdCBpbXBlcmRpZXQuIE51bGxhIGRhcGlidXMgcHVydXMgdXQgbG9yZW0gZmF1Y2lidXMsIGF0" +
+ "IGdyYXZpZGEgdGVsbHVzIGV1aXNtb2QuIEN1cmFiaXR1ciBleCByaXN1cywgZWdlc3RhcyBpbiBwb3J0YSBhbWV0Lg==",
+ ),
+ },
+ },
+ false,
+ },
+
+ // inline attachments
+ {
+ "Single Inline Attachment",
+ []attachment{{"name.txt", strings.NewReader("test"), true, ""}},
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "inline;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ },
+ false,
+ },
+ {
+ "Single Inline Attachment with specified MIME type",
+ []attachment{{"name.txt", strings.NewReader("test"), true, "text/csv; charset=utf-8"}},
+ []testAttachment{
+ {
+ contentType: "text/csv; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "inline;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Inline Attachments - same types",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), true, ""},
+ {"different.txt", strings.NewReader("another"), true, ""},
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "inline;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ disposition: "inline;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ data: *bytes.NewBufferString("YW5vdGhlcg=="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Attachments - One Inline, One not",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), false, ""},
+ {"different.txt", strings.NewReader("another"), true, ""},
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "attachment;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ disposition: "inline;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ data: *bytes.NewBufferString("YW5vdGhlcg=="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Inline Attachments - different types",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), true, ""},
+ {"html.txt", strings.NewReader(""), true, ""},
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "inline;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "text/html; charset=utf-8;\n\tfilename=\"html.txt\"; name=\"html.txt\"",
+ disposition: "inline;\n\tfilename=\"html.txt\"; name=\"html.txt\"",
+ data: *bytes.NewBufferString("PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Inline Attachments - specified MIME types",
+ []attachment{
+ {"name.txt", strings.NewReader("test"), true, "text/csv; charset=utf-8"},
+ {"different.txt", strings.NewReader(""), true, "application/xml"},
+ },
+ []testAttachment{
+ {
+ contentType: "text/csv; charset=utf-8;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ disposition: "inline;\n\tfilename=\"name.txt\"; name=\"name.txt\"",
+ data: *bytes.NewBufferString("dGVzdA=="),
+ },
+ {
+ contentType: "application/xml;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ disposition: "inline;\n\tfilename=\"different.txt\"; name=\"different.txt\"",
+ data: *bytes.NewBufferString("PGh0bWw+PGhlYWQ+PC9oZWFkPjwvaHRtbD4="),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Inline Attachments - >512 bytes, longer first",
+ []attachment{
+ {
+ "550.txt",
+ strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris ut nisl felis. " +
+ "Aenean felis justo, gravida eget leo aliquet, molestie aliquam risus. Vestibulum " +
+ "et nibh rhoncus, malesuada tellus eget, pellentesque diam. Sed venenatis vitae " +
+ "erat vel ullamcorper. Aenean rutrum pulvinar purus eget cursus. Integer at iaculis " +
+ "arcu. Maecenas mollis nulla dolor, et ultricies massa posuere quis. Nulla facilisi. " +
+ "Proin luctus nec nisl at imperdiet. Nulla dapibus purus ut lorem faucibus, at gravida " +
+ "tellus euismod. Curabitur ex risus, egestas in porta amet.",
+ ),
+ true,
+ "",
+ },
+ {
+ "520.txt", strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu vestibulum dolor. " +
+ "Nunc ac posuere felis, a mattis leo. Duis elementum tempor leo, sed efficitur nunc. " +
+ "Cras ornare feugiat vulputate. Maecenas sit amet felis lobortis ipsum dignissim euismod. " +
+ "Vestibulum id ullamcorper nulla, tincidunt hendrerit justo. Donec vitae eros quam. Nulla " +
+ "accumsan porta sapien, in consequat mauris fermentum ac. In at sem lobortis, auctor metus " +
+ "rutrum, blandit ipsum. Praesent commodo porta semper. Etiam dignissim libero nullam.",
+ ),
+ true,
+ "",
+ },
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ disposition: "inline;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gTWF1cmlzIHV0IG5pc" +
+ "2wgZmVsaXMuIEFlbmVhbiBmZWxpcyBqdXN0bywgZ3JhdmlkYSBlZ2V0IGxlbyBhbGlxdWV0LCBtb2xlc3RpZSBhbGlxdW" +
+ "FtIHJpc3VzLiBWZXN0aWJ1bHVtIGV0IG5pYmggcmhvbmN1cywgbWFsZXN1YWRhIHRlbGx1cyBlZ2V0LCBwZWxsZW50ZXN" +
+ "xdWUgZGlhbS4gU2VkIHZlbmVuYXRpcyB2aXRhZSBlcmF0IHZlbCB1bGxhbWNvcnBlci4gQWVuZWFuIHJ1dHJ1bSBwdWx2" +
+ "aW5hciBwdXJ1cyBlZ2V0IGN1cnN1cy4gSW50ZWdlciBhdCBpYWN1bGlzIGFyY3UuIE1hZWNlbmFzIG1vbGxpcyBudWxsY" +
+ "SBkb2xvciwgZXQgdWx0cmljaWVzIG1hc3NhIHBvc3VlcmUgcXVpcy4gTnVsbGEgZmFjaWxpc2kuIFByb2luIGx1Y3R1cy" +
+ "BuZWMgbmlzbCBhdCBpbXBlcmRpZXQuIE51bGxhIGRhcGlidXMgcHVydXMgdXQgbG9yZW0gZmF1Y2lidXMsIGF0IGdyYXZ" +
+ "pZGEgdGVsbHVzIGV1aXNtb2QuIEN1cmFiaXR1ciBleCByaXN1cywgZWdlc3RhcyBpbiBwb3J0YSBhbWV0Lg==",
+ ),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ disposition: "inline;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gRG9uZWMgZXUgdmVz" +
+ "dGlidWx1bSBkb2xvci4gTnVuYyBhYyBwb3N1ZXJlIGZlbGlzLCBhIG1hdHRpcyBsZW8uIER1aXMgZWxlbWVudHVtIHRl" +
+ "bXBvciBsZW8sIHNlZCBlZmZpY2l0dXIgbnVuYy4gQ3JhcyBvcm5hcmUgZmV1Z2lhdCB2dWxwdXRhdGUuIE1hZWNlbmFz" +
+ "IHNpdCBhbWV0IGZlbGlzIGxvYm9ydGlzIGlwc3VtIGRpZ25pc3NpbSBldWlzbW9kLiBWZXN0aWJ1bHVtIGlkIHVsbGFt" +
+ "Y29ycGVyIG51bGxhLCB0aW5jaWR1bnQgaGVuZHJlcml0IGp1c3RvLiBEb25lYyB2aXRhZSBlcm9zIHF1YW0uIE51bGxh" +
+ "IGFjY3Vtc2FuIHBvcnRhIHNhcGllbiwgaW4gY29uc2VxdWF0IG1hdXJpcyBmZXJtZW50dW0gYWMuIEluIGF0IHNlbSBs" +
+ "b2JvcnRpcywgYXVjdG9yIG1ldHVzIHJ1dHJ1bSwgYmxhbmRpdCBpcHN1bS4gUHJhZXNlbnQgY29tbW9kbyBwb3J0YSBz" +
+ "ZW1wZXIuIEV0aWFtIGRpZ25pc3NpbSBsaWJlcm8gbnVsbGFtLg==",
+ ),
+ },
+ },
+ false,
+ },
+ {
+ "Multiple Inline Attachments - >512 bytes, shorter first",
+ []attachment{
+ {
+ "520.txt",
+ strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu vestibulum dolor. Nunc ac " +
+ "posuere felis, a mattis leo. Duis elementum tempor leo, sed efficitur nunc. Cras ornare " +
+ "feugiat vulputate. Maecenas sit amet felis lobortis ipsum dignissim euismod. Vestibulum " +
+ "id ullamcorper nulla, tincidunt hendrerit justo. Donec vitae eros quam. Nulla accumsan " +
+ "porta sapien, in consequat mauris fermentum ac. In at sem lobortis, auctor metus rutrum, " +
+ "blandit ipsum. Praesent commodo porta semper. Etiam dignissim libero nullam.",
+ ),
+ true,
+ "",
+ },
+ {
+ "550.txt",
+ strings.NewReader(
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris ut nisl felis. Aenean felis " +
+ "justo, gravida eget leo aliquet, molestie aliquam risus. Vestibulum et nibh rhoncus, " +
+ "malesuada tellus eget, pellentesque diam. Sed venenatis vitae erat vel ullamcorper. " +
+ "Aenean rutrum pulvinar purus eget cursus. Integer at iaculis arcu. Maecenas mollis " +
+ "nulla dolor, et ultricies massa posuere quis. Nulla facilisi. Proin luctus nec nisl " +
+ "at imperdiet. Nulla dapibus purus ut lorem faucibus, at gravida tellus euismod. Curabitur " +
+ "ex risus, egestas in porta amet.",
+ ),
+ true,
+ "",
+ },
+ },
+ []testAttachment{
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ disposition: "inline;\n\tfilename=\"520.txt\"; name=\"520.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gRG9uZWMgZXUgdmVz" +
+ "dGlidWx1bSBkb2xvci4gTnVuYyBhYyBwb3N1ZXJlIGZlbGlzLCBhIG1hdHRpcyBsZW8uIER1aXMgZWxlbWVudHVtIHRl" +
+ "bXBvciBsZW8sIHNlZCBlZmZpY2l0dXIgbnVuYy4gQ3JhcyBvcm5hcmUgZmV1Z2lhdCB2dWxwdXRhdGUuIE1hZWNlbmFz" +
+ "IHNpdCBhbWV0IGZlbGlzIGxvYm9ydGlzIGlwc3VtIGRpZ25pc3NpbSBldWlzbW9kLiBWZXN0aWJ1bHVtIGlkIHVsbGFt" +
+ "Y29ycGVyIG51bGxhLCB0aW5jaWR1bnQgaGVuZHJlcml0IGp1c3RvLiBEb25lYyB2aXRhZSBlcm9zIHF1YW0uIE51bGxh" +
+ "IGFjY3Vtc2FuIHBvcnRhIHNhcGllbiwgaW4gY29uc2VxdWF0IG1hdXJpcyBmZXJtZW50dW0gYWMuIEluIGF0IHNlbSBs" +
+ "b2JvcnRpcywgYXVjdG9yIG1ldHVzIHJ1dHJ1bSwgYmxhbmRpdCBpcHN1bS4gUHJhZXNlbnQgY29tbW9kbyBwb3J0YSBz" +
+ "ZW1wZXIuIEV0aWFtIGRpZ25pc3NpbSBsaWJlcm8gbnVsbGFtLg==",
+ ),
+ },
+ {
+ contentType: "text/plain; charset=utf-8;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ disposition: "inline;\n\tfilename=\"550.txt\"; name=\"550.txt\"",
+ data: *bytes.NewBufferString(
+ "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gTWF1cmlzIHV0IG5p" +
+ "c2wgZmVsaXMuIEFlbmVhbiBmZWxpcyBqdXN0bywgZ3JhdmlkYSBlZ2V0IGxlbyBhbGlxdWV0LCBtb2xlc3RpZSBhbGlx" +
+ "dWFtIHJpc3VzLiBWZXN0aWJ1bHVtIGV0IG5pYmggcmhvbmN1cywgbWFsZXN1YWRhIHRlbGx1cyBlZ2V0LCBwZWxsZW50" +
+ "ZXNxdWUgZGlhbS4gU2VkIHZlbmVuYXRpcyB2aXRhZSBlcmF0IHZlbCB1bGxhbWNvcnBlci4gQWVuZWFuIHJ1dHJ1bSBw" +
+ "dWx2aW5hciBwdXJ1cyBlZ2V0IGN1cnN1cy4gSW50ZWdlciBhdCBpYWN1bGlzIGFyY3UuIE1hZWNlbmFzIG1vbGxpcyBu" +
+ "dWxsYSBkb2xvciwgZXQgdWx0cmljaWVzIG1hc3NhIHBvc3VlcmUgcXVpcy4gTnVsbGEgZmFjaWxpc2kuIFByb2luIGx1" +
+ "Y3R1cyBuZWMgbmlzbCBhdCBpbXBlcmRpZXQuIE51bGxhIGRhcGlidXMgcHVydXMgdXQgbG9yZW0gZmF1Y2lidXMsIGF0" +
+ "IGdyYXZpZGEgdGVsbHVzIGV1aXNtb2QuIEN1cmFiaXR1ciBleCByaXN1cywgZWdlc3RhcyBpbiBwb3J0YSBhbWV0Lg==",
+ ),
+ },
+ },
+ false,
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := MailYak{attachments: tt.rattachments}
+ pc := testPartCreator{}
+
+ if err := m.writeAttachments(&pc, nopBuilder{}); (err != nil) != tt.wantErr {
+ t.Errorf("%q. MailYak.writeAttachments() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ // Did we get enough attachments?
+ if len(tt.want) != len(pc.attachments) {
+ t.Fatalf("%q. MailYak.writeAttachments() unexpected number of attachments = %v, want %v", tt.name, len(pc.attachments), len(tt.want))
+ }
+
+ for i, want := range tt.want {
+ got := pc.attachments[i]
+
+ if want.contentType != got.contentType {
+ t.Errorf("%q. MailYak.writeAttachments() content type = %v, want %v", tt.name, want.contentType, got.contentType)
+ }
+
+ if want.disposition != got.disposition {
+ t.Errorf("%q. MailYak.writeAttachments() disposition = %v, want %v", tt.name, want.disposition, got.disposition)
+ }
+
+ if !bytes.Equal(want.data.Bytes(), got.data.Bytes()) {
+ t.Errorf("%q. MailYak.writeAttachments() data = %v, want %v", tt.name, want.data.String(), got.data.String())
+ }
+ }
+ })
+ }
+}
diff --git a/examples_test.go b/examples_test.go
new file mode 100644
index 0000000..6df3da2
--- /dev/null
+++ b/examples_test.go
@@ -0,0 +1,176 @@
+// This package provides a easy to use MIME email composer with support for
+// attachments.
+package mailyak
+
+import (
+ "bytes"
+ "crypto/tls"
+ "io"
+ "net/smtp"
+ "text/template"
+)
+
+func Example() {
+ // Create a new email - specify the SMTP host:port and auth (or nil if not
+ // needed).
+ //
+ // If you want to connect using TLS, use NewWithTLS() instead.
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+
+ mail.To("dom@itsallbroken.com")
+ mail.From("jsmith@example.com")
+ mail.FromName("Prince Anybody")
+
+ mail.Subject("Business proposition")
+
+ // Add a custom header
+ mail.AddHeader("X-TOTALLY-NOT-A-SCAM", "true")
+
+ // mail.HTMLWriter() and mail.PlainWriter() implement io.Writer, so you can
+ // do handy things like parse a template directly into the email body - here
+ // we just use io.WriteString()
+ if _, err := io.WriteString(mail.HTML(), "So long, and thanks for all the fish."); err != nil {
+ panic(" :( ")
+ }
+
+ // Or set the body using a string helper
+ mail.Plain().Set("Get a real email client")
+
+ // And you're done!
+ if err := mail.Send(); err != nil {
+ panic(" :( ")
+ }
+}
+
+func Example_attachments() {
+ // This will be our attachment data
+ buf := &bytes.Buffer{}
+ _, _ = io.WriteString(buf, "We're in the stickiest situation since Sticky the Stick Insect got stuck on a sticky bun.")
+
+ // Create a new email - specify the SMTP host:port and auth (or nil if not
+ // needed).
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+
+ mail.To("dom@itsallbroken.com")
+ mail.From("jsmith@example.com")
+ mail.HTML().Set("I am an email")
+
+ // buf could be anything that implements io.Reader, like a file on disk or
+ // an in-memory buffer.
+ mail.Attach("sticky.txt", buf)
+
+ if err := mail.Send(); err != nil {
+ panic(" :( ")
+ }
+}
+
+func ExampleBodyPart_string() {
+ // Create a new email - specify the SMTP host and auth
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+
+ // Set the plain text email content using a string
+ mail.Plain().Set("Get a real email client")
+}
+
+func ExampleNewWithTLS() {
+ // Create a new MailYak instance that uses an explicit TLS connection. This
+ // ensures no communication is performed in plain-text.
+ //
+ // Specify the SMTP host:port to connect to, the authentication credentials
+ // (or nil if not needed), and use an automatically generated TLS
+ // configuration by passing nil as the tls.Config argument.
+ mail, err := NewWithTLS("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"), nil)
+ if err != nil {
+ panic("failed to initialise a TLS instance :(")
+ }
+
+ mail.Plain().Set("Have some encrypted goodness")
+ if err := mail.Send(); err != nil {
+ panic(" :( ")
+ }
+}
+
+func ExampleNewWithTLS_with_config() {
+ // Create a new MailYak instance that uses an explicit TLS connection. This
+ // ensures no communication is performed in plain-text.
+ //
+ // Specify the SMTP host:port to connect to, the authentication credentials
+ // (or nil if not needed), and use the tls.Config provided.
+ mail, err := NewWithTLS(
+ "mail.host.com:25",
+ smtp.PlainAuth("", "user", "pass", "mail.host.com"),
+ &tls.Config{
+ // ServerName is used to verify the hostname on the returned
+ // certificates unless InsecureSkipVerify is given. It is also included
+ // in the client's handshake to support virtual hosting unless it is
+ // an IP address.
+ ServerName: "mail.host.com",
+
+ // Negotiate a connection that uses at least TLS v1.2, or refuse the
+ // connection if the server does not support it. Most do, and it is
+ // a very good idea to enforce it!
+ MinVersion: tls.VersionTLS12,
+ },
+ )
+ if err != nil {
+ panic("failed to initialise a TLS instance :(")
+ }
+
+ mail.Plain().Set("Have some encrypted goodness")
+ if err := mail.Send(); err != nil {
+ panic(" :( ")
+ }
+}
+
+func ExampleBodyPart_templates() {
+ // Create a new email
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+
+ // Our pretend template data
+ tmplData := struct {
+ Language string
+ }{"Go"}
+
+ // Compile a template
+ tmpl, err := template.New("html").Parse("I am an email template in {{ .Language }}")
+ if err != nil {
+ panic(" :( ")
+ }
+
+ // Execute the template directly into the email body
+ if err := tmpl.Execute(mail.HTML(), tmplData); err != nil {
+ panic(" :( ")
+ }
+}
+
+func ExampleMailYak_AttachInline() {
+ // Create a new email
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+ mail.To("dom@itsallbroken.com")
+ mail.From("jsmith@example.com")
+
+ // Initialise an io.Reader that contains your image (typically read from
+ // disk, or embedded in memory).
+ //
+ // Here we use an empty buffer as a mock.
+ imageBuffer := &bytes.Buffer{}
+
+ // Add the image as an attachment.
+ //
+ // To reference it, use the name as the cid value.
+ mail.AttachInline("myimage", imageBuffer)
+
+ // Set the HTML body, which includes the inline CID reference.
+ mail.HTML().Set(`
+
+
+
+
+
+ `)
+
+ // Send it!
+ if err := mail.Send(); err != nil {
+ panic(" :( ")
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..058f185
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/domodwyer/mailyak/v3
+
+go 1.12
diff --git a/int-test/cert.pem b/int-test/cert.pem
new file mode 100644
index 0000000..3cbaf47
--- /dev/null
+++ b/int-test/cert.pem
@@ -0,0 +1,46 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDr1aVeF6PO1vnb
+qDWpHzdO2yLX85g1bBZENl1vEBotdCnPZM4Sf+nLKDKVkV/OZiAHRK39VsdmYhTa
+/AheRymClUc1ruaGmHxrlD6RpFkbJQVCO+Jljk9VpBmfJJ/4y7uW6iDyKgvWRMrZ
+daZvUwhlsdwayONZQoE3mMzQc7d4EZqxc5a7veTEcMgdjol40H7b5PCaQjhnoJnl
+jtO6S7MHy/A7R6DHRHJ/VXFYNdZ0dXH5dGs8pwqHNhqCigaGPAR5xBYyLXqe68Es
+qRzbHhgjZhj1fWP3ZEtydY+fovVDAtZojBt9YmtUhYhqikMFdeb804USy9pobjJd
+li8PthP/AgMBAAECggEAJjOdbfh2WHMKVkKRLqwX0XY91q1m4oB3uuTzjaIWG3bN
+rwKtPuHphTcluoSp+E0iswq1BGyiMDuDFVXuJRdx6e00c8W5IeRhgyuoVv9AT91X
+VLtOmRTMVRsX62eLYkneQTv7yj51XGgYU9Fy/GV+3rpI5S3VZggX1mGyC7Iy7gfc
+YxjoqcXlIwIFQne4H9Nu3xvGrhfIS8Ug7pFNFw5GOt1cHZ8wSNU7ZcyCC0A6sIjQ
+RVfSjzT/YAxdF7O+g/aNPxXbXaaLItJxsrSpvX5suCuX63y7acskVzvuG2WiU2XY
+91XdZavAMJEF0TvbOsYYwm4WJWZRhlOcD9YVkTQmQQKBgQD4kOA77yma6yTisMnW
+IUBEEORUCJSGBhxjXkHEdGwUy+sOT4WtykHwQYkrmoAjpsD+7U7bwNPi2lc+5Ih+
+MnhcJU8DnI53ab9Gfz9TJugXGRkgWfOIm4fobI/kutnqzJzw7LT3ms20Kl3iOx6c
+/0RetL0AKRw6c4Acc0+DLYJ/uwKBgQDy40sdAFDLRh8iFdYAIjuSH2+XghbgBDUB
+AGYM8wgAyWR53fsaV72JHQEiotbuAqwic+73rpKP690wyccfMeG5K61YVemm9fSe
+DMDg/qw1Wr0/C1Fb4G3kBtrtqLK3rS6d+s67C9Zbz6Xa/Woww5r47ppEZQt6nKS4
+RIJYXjSOjQKBgDAK+Hw9FqKNznxORUSw+pXtWeRwIBzjc3s1TarmAbnnTBBhCGp1
+zFbeo9+cpiW95lGfln1dANc48mICk+soYCEXSPVevh6QftrmX1v4CiXkwPmCyVJf
+FV60FP/Vqab7U14xsiylbBrlkW3XlWws3o8biehNCdq2Vk2pb/XfxvhfAoGANCcJ
+4dhHgN41oSP/J1gyYlXNqEKOlttQZj6nqvAkL8cOg/xeBnIAiIhpzf4ZGFIKk/tA
+vH4Hik+i1u78in4zcYcnWWhZGziEpcsnPyhv4aTyLa9IcOnnrqaqK42lkjrlX5aS
+/Sa1iFE106fGPWJCzGCvTzBDHrizxb0wH7lan6ECgYBml8NlHVPjU9IiwwlPR0hb
+ne8XGuTXM8BolJxU1TBiOUYYe2xSBd8d18+fZWlcC0DOFaa8sh8niQXhIHMs7C7E
+CYvJ3IGNwmxFNo4f6YHVrnZ+8zdolIPHTSQ91Mpjhit6oHEMMf18tjcPvaNOD6Cz
+NBLG/DBNsoYu4s1dvhcsfw==
+-----END PRIVATE KEY-----
+-----BEGIN CERTIFICATE-----
+MIIC7jCCAdYCCQCy1scIq5lKmDANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJH
+QjEPMA0GA1UECAwGTG9uZG9uMRkwFwYDVQQDDBBpdHNhbGxicm9rZW4uY29tMB4X
+DTIwMTEwNTEzMDUxN1oXDTIwMTIwNTEzMDUxN1owOTELMAkGA1UEBhMCR0IxDzAN
+BgNVBAgMBkxvbmRvbjEZMBcGA1UEAwwQaXRzYWxsYnJva2VuLmNvbTCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBAOvVpV4Xo87W+duoNakfN07bItfzmDVs
+FkQ2XW8QGi10Kc9kzhJ/6csoMpWRX85mIAdErf1Wx2ZiFNr8CF5HKYKVRzWu5oaY
+fGuUPpGkWRslBUI74mWOT1WkGZ8kn/jLu5bqIPIqC9ZEytl1pm9TCGWx3BrI41lC
+gTeYzNBzt3gRmrFzlru95MRwyB2OiXjQftvk8JpCOGegmeWO07pLswfL8DtHoMdE
+cn9VcVg11nR1cfl0azynCoc2GoKKBoY8BHnEFjItep7rwSypHNseGCNmGPV9Y/dk
+S3J1j5+i9UMC1miMG31ia1SFiGqKQwV15vzThRLL2mhuMl2WLw+2E/8CAwEAATAN
+BgkqhkiG9w0BAQsFAAOCAQEAjoXYVbZA9xygh99lVuuUaZboXn+dLGJHwzD+6cq2
+/r7abH2sYcbDEa+oBt34nS8B+R6fo5GW1IYi3ILhZLzKSR1LvpUP567furYjkUdI
+34TC6NVvdXNUyCTV9BRHgTxfXxF68vee4j5dQQ0h4hpX4/IjJ1ehljO2DI66qAOF
+vC76Tkal5Kvit5QufZc4/oWg8gYBc6XIrQbOEgqO3Xfq4A63eumt4+Zk/hdYCOBR
+Gg2bEFgIlL/SOJXjpQ28z+6xnzKuA5A98kHDTxyKlhgYIltQmJCVM3asEns41ERX
+b74NNxE1qvkdlL5j+uE+nwXlL3K3IvGdXnTk4EoO1UgL4w==
+-----END CERTIFICATE-----
diff --git a/int-test/run.sh b/int-test/run.sh
new file mode 100755
index 0000000..25bba01
--- /dev/null
+++ b/int-test/run.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+
+# This script runs a quick integration test using MailHog as the SMTP server and
+# socat to provide a TLS wrapper for TLS integratoin tests.
+#
+# https://github.com/mailhog/MailHog
+#
+# If you wish to run these tests, ensure mailhog and socat are in your path.
+# You'll probably need OpenSSL too.
+#
+# Results must be verified manually, either with the UI or the MailHog API:
+#
+# curl http://127.0.0.1${MAILHOG_API_PORT}/api/v2/messages -s | \
+# jq '.total, .items[].Content.Headers.Subject'
+#
+#
+
+# Define the ports the services listen on
+SMTP_PORT=${SMTP_PORT:="7025"}
+TLS_PORT=${TLS_PORT:="7026"}
+
+# PID files for cleanup
+MAILHOG_PID="$(pwd)/mailhog.pid"
+SOCAT_PID="$(pwd)/socat.pid"
+
+INT_DIR=$(dirname "$(realpath -s "$0")")
+
+# kill -9 a process with a pidfile at the first argument.
+function stop_pidfile() {
+ pid_file=$1
+ if [ -f "${pid_file}" ]; then
+ kill -9 "$(cat "${pid_file}")" || true
+ rm "${pid_file}"
+ fi
+}
+
+function cleanup() {
+ stop_pidfile "${MAILHOG_PID}";
+ stop_pidfile "${SOCAT_PID}";
+}
+trap cleanup EXIT
+
+mailhog -smtp-bind-addr=127.0.0.1:${SMTP_PORT} & echo "$!" > "${MAILHOG_PID}"
+socat -v openssl-listen:${TLS_PORT},cert="${INT_DIR}/cert.pem",verify=0,reuseaddr,fork tcp4:127.0.0.1:${SMTP_PORT} & echo "$!" > "${SOCAT_PID}"
+
+wait "$(cat "${MAILHOG_PID}")"
\ No newline at end of file
diff --git a/integration_test.go b/integration_test.go
new file mode 100644
index 0000000..9fb1071
--- /dev/null
+++ b/integration_test.go
@@ -0,0 +1,234 @@
+package mailyak
+
+import (
+ "bytes"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/tls"
+ "encoding/hex"
+ "net/smtp"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func tlsEndpoint(t *testing.T) string {
+ s := os.Getenv("MAILYAK_TLS_ENDPOINT")
+ if s == "" {
+ t.Log("set MAILYAK_TLS_ENDPOINT to run TLS integration tests")
+ t.SkipNow()
+ }
+ return s
+}
+
+func plaintextEndpoint(t *testing.T) string {
+ s := os.Getenv("MAILYAK_PLAINTEXT_ENDPOINT")
+ if s == "" {
+ t.Log("set MAILYAK_PLAINTEXT_ENDPOINT to run plain-text integration tests")
+ t.SkipNow()
+ }
+ return s
+}
+
+func TestIntegration_TLS(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ auth smtp.Auth
+
+ fn func(m *MailYak)
+
+ wantErr error
+ }{
+ {
+ name: "ok",
+ fn: func(m *MailYak) {
+ m.From("from@example.org")
+ m.FromName("From Example")
+ m.To("to@example.org")
+ m.Bcc("bcc1@example.org", "bcc2@example.org")
+ m.Subject("TLS test")
+ m.ReplyTo("replies@example.org")
+ m.HTML().Set("HTML part: this is just a test.")
+ m.Plain().Set("Plain text part: this is also just a test.")
+ m.Attach("test.html", strings.NewReader(""))
+ m.Attach("test2.html", strings.NewReader(""))
+ m.AddHeader("Precedence", "bulk")
+ },
+ wantErr: nil,
+ },
+ {
+ name: "empty",
+ fn: func(m *MailYak) {
+ m.From("from@example.org")
+ m.FromName("From Example")
+ m.To("dom@eitsallbroken.com")
+ m.Bcc("bcc1@example.org", "bcc2@example.org")
+ m.Subject("TLS empty")
+ m.ReplyTo("replies@example.org")
+ },
+ wantErr: nil,
+ },
+ {
+ name: "authenticated",
+ auth: smtp.PlainAuth("ident", "user", "pass", "127.0.0.1"),
+ fn: func(m *MailYak) {
+ m.From("from@example.org")
+ m.FromName("From Example")
+ m.To("to@example.org")
+ m.Bcc("bcc1@example.org", "bcc2@example.org")
+ m.Subject("TLS test")
+ m.ReplyTo("replies@example.org")
+ m.HTML().Set("HTML part: this is just a test.")
+ m.Plain().Set("Plain text part: this is also just a test.")
+ m.Attach("test.html", strings.NewReader(""))
+ m.Attach("test2.html", strings.NewReader(""))
+ m.AddHeader("Precedence", "bulk")
+ },
+ wantErr: nil,
+ },
+ {
+ name: "binary attachment",
+ fn: func(m *MailYak) {
+ data := make([]byte, 1024*5)
+ _, _ = rand.Read(data)
+ hash := md5.Sum(data)
+ hashString := hex.EncodeToString(hash[:])
+
+ m.From("dom@itsallbroken.com")
+ m.FromName("Dom")
+ m.To("to@example.org")
+ m.Subject("TLS Attachment test")
+ m.ReplyTo("replies@example.org")
+ m.HTML().Set("Attachment MD5: " + hashString)
+ m.Attach("test.bin", bytes.NewReader(data))
+ },
+ wantErr: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ var tt = tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Initialise a TLS mailyak instance with terrible security.
+ mail, err := NewWithTLS(tlsEndpoint(t), tt.auth, &tls.Config{
+ // Please, never do this outside of a test.
+ InsecureSkipVerify: true,
+ ServerName: "127.0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Apply some mutations to the email
+ tt.fn(mail)
+
+ // Send the email
+ err = mail.Send()
+ if !reflect.DeepEqual(err, tt.wantErr) {
+ t.Errorf("got %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestIntegration_PlainText(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ auth smtp.Auth
+
+ fn func(m *MailYak)
+
+ wantErr error
+ }{
+ {
+ name: "ok",
+ fn: func(m *MailYak) {
+ m.From("from@example.org")
+ m.FromName("From Example")
+ m.To("to@example.org")
+ m.Bcc("bcc1@example.org", "bcc2@example.org")
+ m.Subject("PLAIN - Test subject")
+ m.ReplyTo("replies@example.org")
+ m.HTML().Set("HTML part: this is just a test.")
+ m.Plain().Set("Plain text part: this is also just a test.")
+ m.Attach("test.html", strings.NewReader(""))
+ m.Attach("test2.html", strings.NewReader(""))
+ m.AddHeader("Precedence", "bulk")
+ },
+ wantErr: nil,
+ },
+ {
+ name: "empty",
+ fn: func(m *MailYak) {
+ m.From("from@example.org")
+ m.FromName("From Example")
+ m.To("dom@eitsallbroken.com")
+ m.Bcc("bcc1@example.org", "bcc2@example.org")
+ m.Subject("Plaintext empty")
+ m.ReplyTo("replies@example.org")
+ },
+ wantErr: nil,
+ },
+ {
+ name: "authenticated",
+ auth: smtp.PlainAuth("ident", "user", "pass", "127.0.0.1"),
+ fn: func(m *MailYak) {
+ m.From("from@example.org")
+ m.FromName("From Example")
+ m.To("to@example.org")
+ m.Bcc("bcc1@example.org", "bcc2@example.org")
+ m.Subject("PLAIN - TLS test")
+ m.ReplyTo("replies@example.org")
+ m.HTML().Set("HTML part: this is just a test.")
+ m.Plain().Set("Plain text part: this is also just a test.")
+ m.Attach("test.html", strings.NewReader(""))
+ m.Attach("test2.html", strings.NewReader(""))
+ m.AddHeader("Precedence", "bulk")
+ },
+ wantErr: nil,
+ },
+ {
+ name: "binary attachment",
+ fn: func(m *MailYak) {
+ data := make([]byte, 1024*5)
+ _, _ = rand.Read(data)
+ hash := md5.Sum(data)
+ hashString := hex.EncodeToString(hash[:])
+
+ m.From("dom@itsallbroken.com")
+ m.FromName("Dom")
+ m.To("to@example.org")
+ m.Subject("PLAIN - Attachment test")
+ m.ReplyTo("replies@example.org")
+ m.HTML().Set("Attachment MD5: " + hashString)
+ m.Attach("test.bin", bytes.NewReader(data))
+ },
+ wantErr: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ var tt = tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ mail := New(plaintextEndpoint(t), tt.auth)
+
+ // Apply some mutations to the email
+ tt.fn(mail)
+
+ // Send the email
+ err := mail.Send()
+ if !reflect.DeepEqual(err, tt.wantErr) {
+ t.Errorf("got %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/mailyak.go b/mailyak.go
new file mode 100644
index 0000000..bb8f9cf
--- /dev/null
+++ b/mailyak.go
@@ -0,0 +1,230 @@
+package mailyak
+
+import (
+ "bytes"
+ "crypto/tls"
+ "fmt"
+ "net/mail"
+ "net/smtp"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// MailYak is an easy-to-use email builder.
+type MailYak struct {
+ html BodyPart
+ plain BodyPart
+
+ localName string
+ toAddrs []string
+ ccAddrs []string
+ bccAddrs []string
+ subject string
+ fromAddr string
+ fromName string
+ replyTo string
+ headers map[string][]string // arbitrary headers
+ attachments []attachment
+ trimRegex *regexp.Regexp
+ auth smtp.Auth
+ host string
+ sender emailSender
+ writeBccHeader bool
+ date string
+}
+
+// Email Date timestamp format
+const mailDateFormat = time.RFC1123Z
+
+// New returns an instance of MailYak using host as the SMTP server, and
+// authenticating with auth if non-nil.
+//
+// host must include the port number (i.e. "smtp.itsallbroken.com:25")
+//
+// mail := mailyak.New("smtp.itsallbroken.com:25", smtp.PlainAuth(
+// "",
+// "username",
+// "password",
+// "smtp.itsallbroken.com",
+// ))
+//
+// MailYak instances created with New will switch to using TLS after connecting
+// if the remote host supports the STARTTLS command. For an explicit TLS
+// connection, or to provide a custom tls.Config, use NewWithTLS() instead.
+func New(host string, auth smtp.Auth) *MailYak {
+ return &MailYak{
+ headers: map[string][]string{},
+ host: host,
+ auth: auth,
+ sender: newSenderWithStartTLS(host),
+ trimRegex: regexp.MustCompile("\r?\n"),
+ writeBccHeader: false,
+ date: time.Now().Format(mailDateFormat),
+ }
+}
+
+// NewWithTLS returns an instance of MailYak using host as the SMTP server over
+// an explicit TLS connection, and authenticating with auth if non-nil.
+//
+// host must include the port number (i.e. "smtp.itsallbroken.com:25")
+//
+// mail := mailyak.NewWithTLS("smtp.itsallbroken.com:25", smtp.PlainAuth(
+// "",
+// "username",
+// "password",
+// "smtp.itsallbroken.com",
+// ), tlsConfig)
+//
+// If tlsConfig is nil, a sensible default is generated that can connect to
+// host.
+func NewWithTLS(host string, auth smtp.Auth, tlsConfig *tls.Config) (*MailYak, error) {
+ // Construct a default MailYak instance
+ m := New(host, auth)
+
+ // Initialise the TLS sender with the (potentially nil) TLS config, swapping
+ // it with the default STARTTLS sender.
+ var err error
+ m.sender, err = newSenderWithExplicitTLS(host, tlsConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ return m, nil
+}
+
+// Send attempts to send the built email via the configured SMTP server.
+//
+// Attachments are read and the email timestamp is created when Send() is
+// called, and any connection/authentication errors will be returned by Send().
+func (m *MailYak) Send() error {
+ m.date = time.Now().Format(mailDateFormat)
+
+ return m.sender.Send(m)
+}
+
+// MimeBuf returns the buffer containing all the RAW MIME data.
+//
+// MimeBuf is typically used with an API service such as Amazon SES that does
+// not use an SMTP interface.
+func (m *MailYak) MimeBuf() (*bytes.Buffer, error) {
+ m.date = time.Now().Format(mailDateFormat)
+
+ buf := &bytes.Buffer{}
+ if err := m.buildMime(buf); err != nil {
+ return nil, err
+ }
+
+ return buf, nil
+}
+
+// String returns a redacted description of the email state, typically for
+// logging or debugging purposes.
+//
+// Authentication information is not included in the returned string.
+func (m *MailYak) String() string {
+ var (
+ att []string
+ custom string
+ )
+ for _, a := range m.attachments {
+ att = append(att, "{filename: "+a.filename+"}")
+ }
+
+ if len(m.headers) > 0 {
+ var hdrs []string
+ for k, v := range m.headers {
+ hdrs = append(hdrs, fmt.Sprintf("%s: %q", k, v))
+ }
+ custom = strings.Join(hdrs, ", ") + ", "
+ }
+
+ _, isTLSSender := m.sender.(*senderExplicitTLS)
+
+ return fmt.Sprintf(
+ "&MailYak{date: %q, from: %q, fromName: %q, html: %v bytes, plain: %v bytes, toAddrs: %v, "+
+ "bccAddrs: %v, subject: %q, %vhost: %q, attachments (%v): %v, auth set: %v, explicit tls: %v}",
+ m.date,
+ m.fromAddr,
+ m.fromName,
+ len(m.HTML().String()),
+ len(m.Plain().String()),
+ m.toAddrs,
+ m.bccAddrs,
+ m.subject,
+ custom,
+ m.host,
+ len(att),
+ att,
+ m.auth != nil,
+ isTLSSender,
+ )
+}
+
+// HTML returns a BodyPart for the HTML email body.
+func (m *MailYak) HTML() *BodyPart {
+ return &m.html
+}
+
+// Plain returns a BodyPart for the plain-text email body.
+func (m *MailYak) Plain() *BodyPart {
+ return &m.plain
+}
+
+// getLocalName should return the sender domain to be used in the EHLO/HELO
+// command.
+func (m *MailYak) getLocalName() string {
+ return m.localName
+}
+
+// getToAddrs should return a slice of email addresses to be added to the
+// RCPT TO command.
+func (m *MailYak) getToAddrs() []string {
+ // Pre-allocate the slice to avoid growing it, we already know how big it
+ // needs to be.
+ addrs := len(m.toAddrs) + len(m.ccAddrs) + len(m.bccAddrs)
+ out := make([]string, 0, addrs)
+
+ out = append(out, stripNames(m.toAddrs)...)
+ out = append(out, stripNames(m.ccAddrs)...)
+ out = append(out, stripNames(m.bccAddrs)...)
+
+ return out
+}
+
+// getFromAddr should return the address to be used in the MAIL FROM
+// command.
+func (m *MailYak) getFromAddr() string {
+ return m.fromAddr
+}
+
+// getAuth should return the smtp.Auth if configured, nil if not.
+func (m *MailYak) getAuth() smtp.Auth {
+ return m.auth
+}
+
+// stripNames returns a new slice with only the email parts from the RFC 5322 addresses.
+//
+// Or in other words, converts:
+// ["a@example.com", "John ", "invalid"]
+// to
+// ["a@example.com", "b@example.com", "invalid"].
+//
+// Note that invalid addresses are kept as they are.
+func stripNames(addresses []string) []string {
+ result := make([]string, 0, len(addresses))
+
+ for _, original := range addresses {
+ addr, err := mail.ParseAddress(original)
+
+ if err != nil {
+ // add as it is
+ result = append(result, original)
+ } else {
+ // add only the email part
+ result = append(result, addr.Address)
+ }
+ }
+
+ return result
+}
diff --git a/mailyak_test.go b/mailyak_test.go
new file mode 100644
index 0000000..a675972
--- /dev/null
+++ b/mailyak_test.go
@@ -0,0 +1,94 @@
+package mailyak
+
+import (
+ "fmt"
+ "net/smtp"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestMailYakStringer ensures MailYak struct conforms to the Stringer interface.
+func TestMailYakStringer(t *testing.T) {
+ t.Parallel()
+
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+ mail.From("from@example.org")
+ mail.FromName("From Example")
+ mail.To("to@example.org")
+ mail.Bcc("bcc1@example.org", "bcc2@example.org")
+ mail.Subject("Test subject")
+ mail.ReplyTo("replies@example.org")
+ mail.HTML().Set("HTML part: this is just a test.")
+ mail.Plain().Set("Plain text part: this is also just a test.")
+ mail.Attach("test.html", strings.NewReader(""))
+ mail.Attach("test2.html", strings.NewReader(""))
+
+ mail.AddHeader("Precedence", "bulk")
+
+ mail.date = "a date"
+
+ want := "&MailYak{date: \"a date\", from: \"from@example.org\", fromName: \"From Example\", html: 31 bytes, plain: 42 bytes, toAddrs: [to@example.org], bccAddrs: [bcc1@example.org bcc2@example.org], subject: \"Test subject\", Precedence: [\"bulk\"], host: \"mail.host.com:25\", attachments (2): [{filename: test.html} {filename: test2.html}], auth set: true, explicit tls: false}"
+ got := fmt.Sprintf("%+v", mail)
+ if got != want {
+ t.Errorf("MailYak.String() = %v, want %v", got, want)
+ }
+}
+
+// TestMailYakDate ensures two emails sent with the same MailYak instance use
+// different (updated) date timestamps.
+func TestMailYakDate(t *testing.T) {
+ t.Parallel()
+
+ mail := New("mail.host.com:25", smtp.PlainAuth("", "user", "pass", "mail.host.com"))
+ mail.From("from@example.org")
+ mail.To("to@example.org")
+ mail.Subject("Test subject")
+
+ // send two emails at different times (discarding any errors)
+ _, _ = mail.MimeBuf()
+ dateOne := mail.date
+
+ time.Sleep(1 * time.Second)
+
+ _, _ = mail.MimeBuf()
+ dateTwo := mail.date
+
+ if dateOne == dateTwo {
+ t.Errorf("MailYak.Send(): timestamp not updated: %v", dateOne)
+ }
+}
+
+// TestStripNames ensures that the stripNames() method correctly
+// remove the name part of a list of RFC 5322 addresses.
+func TestStripNames(t *testing.T) {
+ addresses := []string{
+ "a@example.com",
+ "b@example.com",
+ "invalid1",
+ "John Doe ",
+ "",
+ "invalid2",
+ }
+
+ expected := map[string]struct{}{
+ "a@example.com": {},
+ "b@example.com": {},
+ "invalid1": {},
+ "c@example.com": {},
+ "d@example.com": {},
+ "invalid2": {},
+ }
+
+ result := stripNames(addresses)
+
+ if len(result) != len(expected) {
+ t.Fatalf("stripNames: Expected %d addresses, got %d: \n%v", len(expected), len(result), result)
+ }
+
+ for _, addr := range result {
+ if _, ok := expected[addr]; !ok {
+ t.Errorf("stripNames: Address %q was not expected", addr)
+ }
+ }
+}
diff --git a/mime.go b/mime.go
new file mode 100644
index 0000000..73de730
--- /dev/null
+++ b/mime.go
@@ -0,0 +1,202 @@
+package mailyak
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "mime/quotedprintable"
+ "net/textproto"
+ "strings"
+)
+
+func (m *MailYak) buildMime(w io.Writer) error {
+ mb, err := randomBoundary()
+ if err != nil {
+ return err
+ }
+
+ ab, err := randomBoundary()
+ if err != nil {
+ return err
+ }
+
+ return m.buildMimeWithBoundaries(w, mb, ab)
+}
+
+// randomBoundary returns a random hexadecimal string used for separating MIME
+// parts.
+//
+// The returned string must be sufficiently random to prevent malicious users
+// from performing content injection attacks.
+func randomBoundary() (string, error) {
+ buf := make([]byte, 30)
+ _, err := io.ReadFull(rand.Reader, buf)
+ if err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+// buildMimeWithBoundaries creates the MIME message using mb and ab as MIME
+// boundaries, and returns the generated MIME data as a buffer.
+func (m *MailYak) buildMimeWithBoundaries(w io.Writer, mb, ab string) error {
+ if err := m.writeHeaders(w); err != nil {
+ return err
+ }
+
+ var (
+ hasBody = m.html.Len() != 0 || m.plain.Len() != 0
+ hasAttachments = len(m.attachments) != 0
+ )
+
+ // if we don't have text/html body or attachments we can skip Content-Type header
+ // in that case next default will be assumed
+ // Content-type: text/plain; charset=us-ascii
+ // See https://datatracker.ietf.org/doc/html/rfc2045#page-14
+ if !hasBody && !hasAttachments {
+ // The RFC said that body can be ommited: https://datatracker.ietf.org/doc/html/rfc822#section-3.1
+ // but for example mailhog can't correctly read message without any body.
+ // This CRLF just separate header section.
+ _, err := fmt.Fprint(w, "\r\n")
+ return err
+ }
+ // Start our multipart/mixed part
+ mixed := multipart.NewWriter(w)
+ if err := mixed.SetBoundary(mb); err != nil {
+ return err
+ }
+
+ // To avoid deferring a mixed.Close(), run the write in a closure and
+ // close the mixed after.
+ tryWrite := func() error {
+ fmt.Fprintf(w, "Content-Type: multipart/mixed;\r\n\tboundary=\"%s\"; charset=UTF-8\r\n\r\n", mixed.Boundary())
+
+ if hasBody {
+ if err := m.writeAlternativePart(mixed, ab); err != nil {
+ return err
+ }
+ }
+ if hasAttachments {
+ if err := m.writeAttachments(mixed, lineSplitterBuilder{}); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ if err := tryWrite(); err != nil {
+ return err
+ }
+
+ return mixed.Close()
+}
+
+// writeHeaders writes the MIME-Version, Date, Reply-To, From, To and Subject headers,
+// plus any custom headers set via AddHeader().
+func (m *MailYak) writeHeaders(w io.Writer) error {
+ if _, err := w.Write([]byte(m.fromHeader())); err != nil {
+ return err
+ }
+
+ if _, err := w.Write([]byte("MIME-Version: 1.0\r\n")); err != nil {
+ return err
+ }
+
+ fmt.Fprintf(w, "Date: %s\r\n", m.date)
+
+ if m.replyTo != "" {
+ fmt.Fprintf(w, "Reply-To: %s\r\n", m.replyTo)
+ }
+
+ fmt.Fprintf(w, "Subject: %s\r\n", m.subject)
+
+ if len(m.toAddrs) > 0 {
+ commaSeparatedToAddrs := strings.Join(m.toAddrs, ",")
+ fmt.Fprintf(w, "To: %s\r\n", commaSeparatedToAddrs)
+ }
+
+ if len(m.ccAddrs) > 0 {
+ commaSeparatedCCAddrs := strings.Join(m.ccAddrs, ",")
+ fmt.Fprintf(w, "CC: %s\r\n", commaSeparatedCCAddrs)
+ }
+
+ if m.writeBccHeader && len(m.bccAddrs) > 0 {
+ commaSeparatedBCCAddrs := strings.Join(m.bccAddrs, ",")
+ fmt.Fprintf(w, "BCC: %s\r\n", commaSeparatedBCCAddrs)
+ }
+
+ for k, values := range m.headers {
+ for _, v := range values {
+ fmt.Fprintf(w, "%s: %s\r\n", k, v)
+ }
+ }
+
+ return nil
+}
+
+// fromHeader returns a correctly formatted From header, optionally with a name
+// component.
+func (m *MailYak) fromHeader() string {
+ if m.fromName == "" {
+ return fmt.Sprintf("From: %s\r\n", m.fromAddr)
+ }
+
+ return fmt.Sprintf("From: %s <%s>\r\n", m.fromName, m.fromAddr)
+}
+
+func (m *MailYak) writeAlternativePart(mixed *multipart.Writer, boundary string) error {
+ ctype := fmt.Sprintf("multipart/alternative;\r\n\tboundary=\"%s\"", boundary)
+
+ altPart, err := mixed.CreatePart(textproto.MIMEHeader{"Content-Type": {ctype}})
+ if err != nil {
+ return err
+ }
+
+ return m.writeBody(altPart, boundary)
+}
+
+// writeBody writes the text/plain and text/html mime parts.
+// It's incorrect to call writeBody without html or plain content.
+func (m *MailYak) writeBody(w io.Writer, boundary string) error {
+ alt := multipart.NewWriter(w)
+
+ if err := alt.SetBoundary(boundary); err != nil {
+ return err
+ }
+
+ var err error
+ writePart := func(ctype string, data []byte) {
+ if len(data) == 0 || err != nil {
+ return
+ }
+
+ c := fmt.Sprintf("%s; charset=UTF-8", ctype)
+
+ var part io.Writer
+ part, err = alt.CreatePart(textproto.MIMEHeader{"Content-Type": {c}, "Content-Transfer-Encoding": {"quoted-printable"}})
+ if err != nil {
+ return
+ }
+
+ var buf bytes.Buffer
+ qpw := quotedprintable.NewWriter(&buf)
+ _, _ = qpw.Write(data)
+ _ = qpw.Close()
+
+ _, err = part.Write(buf.Bytes())
+ }
+
+ writePart("text/plain", m.plain.Bytes())
+ writePart("text/html", m.html.Bytes())
+
+ // If closing the alt fails, and there's not already an error set, return the
+ // close error.
+ if closeErr := alt.Close(); err == nil && closeErr != nil {
+ return closeErr
+ }
+
+ return err
+}
diff --git a/mime_test.go b/mime_test.go
new file mode 100644
index 0000000..7798a08
--- /dev/null
+++ b/mime_test.go
@@ -0,0 +1,657 @@
+package mailyak
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "mime"
+ "mime/multipart"
+ "net/mail"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestMailYakFromHeader ensures the fromHeader method returns valid headers
+func TestMailYakFromHeader(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rfromAddr string
+ rfromName string
+ // Expected results.
+ want string
+ }{
+ {
+ "With name",
+ "dom@itsallbroken.com",
+ "Dom",
+ "From: Dom \r\n",
+ },
+ {
+ "Without name",
+ "dom@itsallbroken.com",
+ "",
+ "From: dom@itsallbroken.com\r\n",
+ },
+ {
+ "Without either",
+ "",
+ "",
+ "From: \r\n",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := MailYak{
+ fromAddr: tt.rfromAddr,
+ fromName: tt.rfromName,
+ }
+
+ if got := m.fromHeader(); got != tt.want {
+ t.Errorf("%q. MailYak.fromHeader() = %v, want %v", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestMailYakWriteHeaders ensures the MIME-Version, Date, Reply-To, From, To and
+// Subject headers are correctly wrote
+func TestMailYakWriteHeaders(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now().Format(time.RFC1123Z)
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rtoAddrs []string
+ rccAddrs []string
+ rbccAddrs []string
+ rsubject string
+ rreplyTo string
+ rwriteBccHeader bool
+ // Expected results.
+ wantBuf string
+ }{
+ {
+ "All fields",
+ []string{"test@itsallbroken.com"},
+ []string{},
+ []string{},
+ "Test",
+ "help@itsallbroken.com",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nReply-To: help@itsallbroken.com\r\nSubject: Test\r\nTo: test@itsallbroken.com\r\n",
+ },
+ {
+ "No reply-to",
+ []string{"test@itsallbroken.com"},
+ []string{},
+ []string{},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com\r\n",
+ },
+ {
+ "Multiple To addresses",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{},
+ []string{},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\n",
+ },
+ {
+ "Single Cc address, Multiple To addresses",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{"cc@itsallbroken.com"},
+ []string{},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\nCC: cc@itsallbroken.com\r\n",
+ },
+ {
+ "Multiple Cc addresses, Multiple To addresses",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{"cc1@itsallbroken.com", "cc2@itsallbroken.com"},
+ []string{},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\nCC: cc1@itsallbroken.com,cc2@itsallbroken.com\r\n",
+ },
+ {
+ "Single Bcc address, Multiple To addresses",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{},
+ []string{"bcc@itsallbroken.com"},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\nBCC: bcc@itsallbroken.com\r\n",
+ },
+ {
+ "Multiple Bcc addresses, Multiple To addresses",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{},
+ []string{"bcc1@itsallbroken.com", "bcc2@itsallbroken.com"},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\nBCC: bcc1@itsallbroken.com,bcc2@itsallbroken.com\r\n",
+ },
+ {
+ "Multiple Bcc addresses, Multiple To addresses",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{},
+ []string{"bcc1@itsallbroken.com", "bcc2@itsallbroken.com"},
+ "",
+ "",
+ false,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\n",
+ },
+ {
+ "All together now",
+ []string{"test@itsallbroken.com", "repairs@itsallbroken.com"},
+ []string{"cc1@itsallbroken.com", "cc2@itsallbroken.com"},
+ []string{"bcc1@itsallbroken.com", "bcc2@itsallbroken.com"},
+ "",
+ "",
+ true,
+ "From: Dom \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: test@itsallbroken.com,repairs@itsallbroken.com\r\nCC: cc1@itsallbroken.com,cc2@itsallbroken.com\r\nBCC: bcc1@itsallbroken.com,bcc2@itsallbroken.com\r\n",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := MailYak{
+ toAddrs: tt.rtoAddrs,
+ subject: tt.rsubject,
+ fromAddr: "dom@itsallbroken.com",
+ fromName: "Dom",
+ replyTo: tt.rreplyTo,
+ ccAddrs: tt.rccAddrs,
+ bccAddrs: tt.rbccAddrs,
+ writeBccHeader: tt.rwriteBccHeader,
+ date: now,
+ }
+
+ buf := &bytes.Buffer{}
+ if err := m.writeHeaders(buf); err != nil {
+ t.Fatal(err)
+ }
+
+ if gotBuf := buf.String(); gotBuf != tt.wantBuf {
+ t.Errorf("%q. MailYak.writeHeaders() = %v, want %v", tt.name, gotBuf, tt.wantBuf)
+ }
+ })
+ }
+}
+
+// TestMailYakWriteBody ensures the correct MIME parts are wrote for the body
+func TestMailYakWriteBody(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rHTML string
+ rPlain string
+ // Parameters.
+ boundary string
+ // Expected results.
+ wantW string
+ wantErr bool
+ }{
+ {
+ "HTML",
+ "HTML",
+ "",
+ "t",
+ "--t\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=UTF-8\r\n\r\nHTML\r\n--t--\r\n",
+ false,
+ },
+ {
+ "Plain text",
+ "",
+ "Plain",
+ "t",
+ "--t\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nPlain\r\n--t--\r\n",
+ false,
+ },
+ {
+ "Both",
+ "HTML",
+ "Plain",
+ "t",
+ "--t\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nPlain\r\n--t\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=UTF-8\r\n\r\nHTML\r\n--t--\r\n",
+ false,
+ },
+ {
+ "Both with long lines",
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+ "t",
+ "--t\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tem=\r\npor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, q=\r\nuis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons=\r\nequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillu=\r\nm dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non pr=\r\noident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n--t\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=UTF-8\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tem=\r\npor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, q=\r\nuis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons=\r\nequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillu=\r\nm dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non pr=\r\noident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n--t--\r\n",
+ false,
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := MailYak{}
+ m.HTML().WriteString(tt.rHTML)
+ m.Plain().WriteString(tt.rPlain)
+
+ w := &bytes.Buffer{}
+ if err := m.writeBody(w, tt.boundary); (err != nil) != tt.wantErr {
+ t.Fatalf("%q. MailYak.writeBody() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ if gotW := w.String(); gotW != tt.wantW {
+ t.Errorf("%q. MailYak.writeBody() = %v, want %v", tt.name, gotW, tt.wantW)
+ }
+ })
+ }
+}
+
+// TestMailYakBuildMime tests all the other mime-related bits combine in a sane way
+func TestMailYakBuildMime(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now().Format(time.RFC1123Z)
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rHTML []byte
+ rPlain []byte
+ rtoAddrs []string
+ rsubject string
+ rfromAddr string
+ rfromName string
+ rreplyTo string
+ rAttachemnt string
+ // Expected results.
+ want string
+ wantErr bool
+ }{
+ {
+ "Empty",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ "",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n",
+ false,
+ },
+ {
+ "HTML",
+ []byte("HTML"),
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ "",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n--alt\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=UTF-8\r\n\r\nHTML\r\n--alt--\r\n\r\n--mixed--\r\n",
+ false,
+ },
+ {
+ "Plain",
+ []byte{},
+ []byte("Plain"),
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ "",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n--alt\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nPlain\r\n--alt--\r\n\r\n--mixed--\r\n",
+ false,
+ },
+ {
+ "Attachemnt",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ "attachment",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Disposition: attachment;\n\tfilename=\"testAttachment\"; name=\"testAttachment\"\r\nContent-ID: \r\nContent-Transfer-Encoding: base64\r\nContent-Type: text/plain; charset=utf-8;\n\tfilename=\"testAttachment\"; name=\"testAttachment\"\r\n\r\nYXR0YWNobWVudA==\r\n--mixed--\r\n",
+ false,
+ },
+ {
+ "Reply-To",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "reply",
+ "",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nReply-To: reply\r\nSubject: \r\nTo: \r\n\r\n",
+ false,
+ },
+ {
+ "From name",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "name",
+ "",
+ "",
+ "From: name <>\r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n",
+ false,
+ },
+ {
+ "From name + address",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "addr",
+ "name",
+ "",
+ "",
+ "From: name \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n",
+ false,
+ },
+ {
+ "From",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "from",
+ "",
+ "",
+ "",
+ "From: from\r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n",
+ false,
+ },
+ {
+ "Subject",
+ []byte{},
+ []byte{},
+ []string{""},
+ "subject",
+ "",
+ "",
+ "",
+ "",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: subject\r\nTo: \r\n\r\n",
+ false,
+ },
+ {
+ "To addresses",
+ []byte{},
+ []byte{},
+ []string{"one", "two"},
+ "",
+ "",
+ "",
+ "",
+ "",
+ "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: one,two\r\n\r\n",
+ false,
+ },
+ }
+
+ regex := regexp.MustCompile("\r?\n")
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ toAddrs: tt.rtoAddrs,
+ subject: tt.rsubject,
+ fromAddr: tt.rfromAddr,
+ fromName: tt.rfromName,
+ replyTo: tt.rreplyTo,
+ trimRegex: regex,
+ date: now,
+ }
+ m.HTML().Write(tt.rHTML)
+ m.Plain().Write(tt.rPlain)
+ if tt.rAttachemnt != "" {
+ m.Attach("testAttachment", strings.NewReader(tt.rAttachemnt))
+ }
+
+ buf := &bytes.Buffer{}
+ err := m.buildMimeWithBoundaries(buf, "mixed", "alt")
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("%q. MailYak.buildMime() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ if buf.String() != tt.want {
+ t.Errorf("%q. MailYak.buildMime() = %v, want %v", tt.name, buf.String(), tt.want)
+ }
+ })
+ }
+}
+
+// TestMailYakBuildMime_withAttachments ensures attachments are correctly added to the MIME message
+func TestMailYakBuildMime_withAttachments(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Receiver fields.
+ rHTML []byte
+ rPlain []byte
+ rtoAddrs []string
+ rsubject string
+ rfromAddr string
+ rfromName string
+ rreplyTo string
+ rattachments []attachment
+ // Expected results.
+ wantAttach []string
+ wantErr bool
+ }{
+ {
+ "No attachment",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ []attachment{},
+ []string{},
+ false,
+ },
+ {
+ "One attachment",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ []attachment{
+ {"test.txt", strings.NewReader("content"), false, ""},
+ },
+ []string{"Y29udGVudA=="},
+ false,
+ },
+ {
+ "One inline attachment",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ []attachment{
+ {"test.txt", strings.NewReader("content"), true, ""},
+ },
+ []string{"Y29udGVudA=="},
+ false,
+ },
+ {
+ "Two attachments",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ []attachment{
+ {"test.txt", strings.NewReader("content"), false, ""},
+ {"another.txt", strings.NewReader("another"), false, ""},
+ },
+ []string{"Y29udGVudA==", "YW5vdGhlcg=="},
+ false,
+ },
+ {
+ "Two inline attachments",
+ []byte{},
+ []byte{},
+ []string{""},
+ "",
+ "",
+ "",
+ "",
+ []attachment{
+ {"test.txt", strings.NewReader("content"), true, ""},
+ {"another.txt", strings.NewReader("another"), true, ""},
+ },
+ []string{"Y29udGVudA==", "YW5vdGhlcg=="},
+ false,
+ },
+ }
+
+ regex := regexp.MustCompile("\r?\n")
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ toAddrs: tt.rtoAddrs,
+ subject: tt.rsubject,
+ fromAddr: tt.rfromAddr,
+ fromName: tt.rfromName,
+ replyTo: tt.rreplyTo,
+ attachments: tt.rattachments,
+ trimRegex: regex,
+ }
+ m.HTML().Write(tt.rHTML)
+ m.Plain().Write(tt.rPlain)
+
+ buf := &bytes.Buffer{}
+ err := m.buildMimeWithBoundaries(buf, "mixed", "alt")
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("%q. MailYak.buildMime() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ seen := 0
+ msg, err := mail.ReadMessage(buf)
+ if err != nil {
+ t.Fatalf("%q. MailYak.buildMime() error %v", tt.name, err)
+ }
+ contentTypeHeader := msg.Header.Get("Content-Type")
+ if contentTypeHeader == "" {
+ if len(tt.rattachments) != 0 {
+ t.Errorf("%q. MailYak.buildMime() attachments were expected, but Content-Type header wasn't set", tt.name)
+ }
+ return
+ }
+ mediaType, mediaTypeParams, err := mime.ParseMediaType(contentTypeHeader)
+ if err != nil {
+ t.Fatalf("%q. MailYak.buildMime() error %v", tt.name, err)
+ }
+ if mediaType != "multipart/mixed" {
+ t.Fatalf("%q. MailYak.buildMime() Content-Type media type multipart/mixed was expected, but got %q", tt.name, mediaType)
+ }
+ mr := multipart.NewReader(msg.Body, mediaTypeParams["boundary"])
+
+ // Itterate over the mime parts, look for attachments
+ for contentTypeHeader != "" {
+ p, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Errorf("%q. MailYak.buildMime() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ // Read the attachment data
+ slurp, err := ioutil.ReadAll(p)
+ if err != nil {
+ t.Errorf("%q. MailYak.buildMime() error = %v, wantErr %v", tt.name, err, tt.wantErr)
+ }
+
+ // Skip non-attachments
+ if p.Header.Get("Content-Disposition") == "" {
+ continue
+ }
+
+ // Run through our attachments looking for a match
+ for i, attch := range tt.rattachments {
+ // Check Disposition header
+ var disp string
+ if attch.inline {
+ disp = "inline; filename=%q; name=%q"
+ } else {
+ disp = "attachment; filename=%q; name=%q"
+ }
+ if p.Header.Get("Content-Disposition") != fmt.Sprintf(disp, attch.filename, attch.filename) {
+ continue
+ }
+
+ // Check data
+ if !bytes.Equal(slurp, []byte(tt.wantAttach[i])) {
+ fmt.Printf("Part %q: %q\n", p.Header.Get("Content-Disposition"), slurp)
+ continue
+ }
+
+ seen++
+ }
+
+ }
+
+ // Did we see all the expected attachments?
+ if seen != len(tt.rattachments) {
+ t.Errorf("%q. MailYak.buildMime() didn't find all attachments in mime body", tt.name)
+ }
+ })
+ }
+}
diff --git a/package.go b/package.go
new file mode 100644
index 0000000..01bd0a0
--- /dev/null
+++ b/package.go
@@ -0,0 +1,18 @@
+// Package mailyak provides a simple interface for generating MIME compliant
+// emails, and optionally sending them over SMTP.
+//
+// Both plain-text and HTML email body content is supported, and their types
+// implement io.Writer allowing easy composition directly from templating
+// engines, etc.
+//
+// Attachments are fully supported including inline attachments, with anything
+// that implements io.Reader suitable as a source (like files on disk, in-memory
+// buffers, etc).
+//
+// The raw MIME content can be retrieved using MimeBuf(), typically used with an
+// API service such as Amazon SES that does not require using an SMTP interface.
+//
+// MailYak supports both plain-text SMTP (which is automatically upgraded to a
+// secure connection with STARTTLS if supported by the SMTP server) and explicit
+// TLS connections.
+package mailyak
diff --git a/sender.go b/sender.go
new file mode 100644
index 0000000..2c4b9ca
--- /dev/null
+++ b/sender.go
@@ -0,0 +1,108 @@
+package mailyak
+
+import (
+ "bufio"
+ "crypto/tls"
+ "io"
+ "net"
+ "net/smtp"
+)
+
+// emailSender abstracts the connection and protocol conversation required to
+// send an email with a remote SMTP server.
+type emailSender interface {
+ Send(m sendableMail) error
+}
+
+// sendableMail provides a set of methods to describe an email to a SMTP server.
+type sendableMail interface {
+ // getLocalName should return the sender domain to be used in the EHLO/HELO
+ // command.
+ getLocalName() string
+
+ // getToAddrs should return a slice of email addresses to be added to the
+ // RCPT TO command.
+ getToAddrs() []string
+
+ // getFromAddr should return the address to be used in the MAIL FROM
+ // command.
+ getFromAddr() string
+
+ // getAuth should return the smtp.Auth if configured, nil if not.
+ getAuth() smtp.Auth
+
+ // buildMime should write the generated MIME to w.
+ //
+ // The emailSender implementation is responsible for providing appropriate
+ // buffering of writes.
+ buildMime(w io.Writer) error
+}
+
+// smtpExchange performs the SMTP protocol conversation necessary to send m over
+// conn.
+//
+// serverName must be the hostname (or IP address) of the remote endpoint.
+func smtpExchange(m sendableMail, conn net.Conn, serverName string, tryTLSUpgrade bool) error {
+ // Connect to the SMTP server
+ c, err := smtp.NewClient(conn, serverName)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = c.Quit() }()
+
+ if localName := m.getLocalName(); localName != "" {
+ if err := c.Hello(localName); err != nil {
+ return err
+ }
+ }
+
+ if tryTLSUpgrade {
+ if ok, _ := c.Extension("STARTTLS"); ok {
+ //nolint:gosec
+ config := &tls.Config{
+ ServerName: serverName,
+ }
+ if err = c.StartTLS(config); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Attempt to authenticate if credentials were provided
+ var nilAuth smtp.Auth
+ if auth := m.getAuth(); auth != nilAuth {
+ if err = c.Auth(auth); err != nil {
+ return err
+ }
+ }
+
+ // Set the from address
+ if err = c.Mail(m.getFromAddr()); err != nil {
+ return err
+ }
+
+ // Add all the recipients
+ for _, to := range m.getToAddrs() {
+ if err = c.Rcpt(to); err != nil {
+ return err
+ }
+ }
+
+ // Start the data session and write the email body
+ dataSession, err := c.Data()
+ if err != nil {
+ return err
+ }
+
+ // Wrap the socket in a small buffer (~4k) to avoid making lots of small
+ // syscalls and therefore reducing CPU usage.
+ buf := bufio.NewWriter(dataSession)
+ if err := m.buildMime(buf); err != nil {
+ return err
+ }
+ if err := buf.Flush(); err != nil {
+ return err
+ }
+
+ return dataSession.Close()
+}
diff --git a/sender_explicit_tls.go b/sender_explicit_tls.go
new file mode 100644
index 0000000..83f69a6
--- /dev/null
+++ b/sender_explicit_tls.go
@@ -0,0 +1,64 @@
+package mailyak
+
+import (
+ "crypto/tls"
+ "net"
+)
+
+// senderExplicitTLS connects to a SMTP server over a TLS connection, performs a
+// handshake and validation according to the provided tls.Config before sending
+// the email.
+type senderExplicitTLS struct {
+ hostAndPort string
+ hostname string
+
+ // tlsConfig is always non-nil
+ tlsConfig *tls.Config
+}
+
+// Connect to the SMTP host configured in m, and send the email.
+func (s *senderExplicitTLS) Send(m sendableMail) error {
+ conn, err := tls.Dial("tcp", s.hostAndPort, s.tlsConfig)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = conn.Close() }()
+
+ // Perform the SMTP protocol conversation, using the provided TLS ServerName
+ // as the SMTP server name.
+ return smtpExchange(m, conn, s.hostname, false)
+}
+
+// newSenderWithExplicitTLS constructs a new senderExplicitTLS.
+//
+// If tlsConfig is nil, a sensible default with maximum compatability is
+// generated.
+func newSenderWithExplicitTLS(hostAndPort string, tlsConfig *tls.Config) (*senderExplicitTLS, error) {
+ // Split the hostname from the addr.
+ //
+ // This hostname is used during TLS negotiation and during SMTP
+ // authentication.
+ hostName, _, err := net.SplitHostPort(hostAndPort)
+ if err != nil {
+ return nil, err
+ }
+
+ if tlsConfig != nil {
+ // Clone the user-provided TLS config to prevent it being
+ // mutated by the caller.
+ tlsConfig = tlsConfig.Clone()
+ } else {
+ // If there is no TLS config provided, initialise a default.
+ //nolint:gosec // Maximum compatability but please use TLS >= 1.2
+ tlsConfig = &tls.Config{
+ ServerName: hostName,
+ }
+ }
+
+ return &senderExplicitTLS{
+ hostAndPort: hostAndPort,
+ hostname: hostName,
+
+ tlsConfig: tlsConfig,
+ }, nil
+}
diff --git a/sender_starttls.go b/sender_starttls.go
new file mode 100644
index 0000000..6040c60
--- /dev/null
+++ b/sender_starttls.go
@@ -0,0 +1,47 @@
+package mailyak
+
+import (
+ "bytes"
+ "net"
+)
+
+// senderWithStartTLS connects to the remote SMTP server, upgrades the
+// connection using STARTTLS if available, and sends the email.
+type senderWithStartTLS struct {
+ hostAndPort string
+ hostname string
+ buf *bytes.Buffer
+}
+
+func (s *senderWithStartTLS) Send(m sendableMail) error {
+ conn, err := net.Dial("tcp", s.hostAndPort)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = conn.Close() }()
+
+ return smtpExchange(m, conn, s.hostname, true)
+}
+
+func newSenderWithStartTLS(hostAndPort string) *senderWithStartTLS {
+ hostName, _, err := net.SplitHostPort(hostAndPort)
+ if err != nil {
+ // Really this should be an error, but we can't return it from the New()
+ // constructor without breaking compatability. Fortunately by the time
+ // it gets to the dial() the user will get a pretty clear error as this
+ // hostAndPort value is almost certainly invalid.
+ //
+ // This hostname must be split from the port so the correct value is
+ // used when performing the SMTP AUTH as the Go SMTP implementation
+ // refuses to send credentials over non-localhost plaintext connections,
+ // and including the port messes this check up (and is probably the
+ // wrong thing to be sending anyway).
+ hostName = hostAndPort
+ }
+
+ return &senderWithStartTLS{
+ hostAndPort: hostAndPort,
+ hostname: hostName,
+ buf: &bytes.Buffer{},
+ }
+}
diff --git a/sender_test.go b/sender_test.go
new file mode 100644
index 0000000..6925769
--- /dev/null
+++ b/sender_test.go
@@ -0,0 +1,453 @@
+package mailyak
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "math/big"
+ "net"
+ "net/smtp"
+ "reflect"
+ "testing"
+ "time"
+)
+
+var (
+ // Test RSA key & self-signed certificate populated by init()
+ testRSAKey *rsa.PrivateKey
+ testCertBytes []byte
+ testCert *x509.Certificate
+)
+
+// Initialise the TLS certificate and key material for TLS tests.
+func init() {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ panic(fmt.Sprintf("failed to generate RSA test key: %v", err))
+ }
+ testRSAKey = key
+
+ // Define the certificate template
+ self := &x509.Certificate{
+ Version: 3,
+ SerialNumber: big.NewInt(42),
+ Issuer: pkix.Name{
+ CommonName: "CA Bananas Inc",
+ },
+ Subject: pkix.Name{
+ CommonName: "The Banana Factory",
+ },
+ NotBefore: time.Now().Add(-1 * time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ IPAddresses: []net.IP{
+ net.IPv4(127, 0, 0, 1),
+ },
+ }
+
+ // Sign the template certificate
+ cert, err := x509.CreateCertificate(rand.Reader, self, self, &testRSAKey.PublicKey, testRSAKey)
+ if err != nil {
+ panic(fmt.Sprintf("failed to generate self-signed test cert: %v", err))
+ }
+ testCertBytes = cert
+
+ // Parse the signed certificate
+ serverCert, err := x509.ParseCertificate(testCertBytes)
+ if err != nil {
+ panic(fmt.Sprintf("failed to bind to localhost: %v", err))
+ }
+ testCert = serverCert
+}
+
+// connAsserts wraps a net.Conn, performing writes and asserts responses within
+// tests.
+//
+// Any errors cause the method to panic.
+type connAsserts struct {
+ net.Conn
+
+ t *testing.T
+ buf *bytes.Buffer
+}
+
+func (c *connAsserts) Read(b []byte) (n int, err error) {
+ n, err = c.Conn.Read(b)
+ c.t.Logf("MailYak -> Server:\n%s\n", hex.Dump(b))
+ return n, err
+}
+
+func (c *connAsserts) Write(b []byte) (n int, err error) {
+ n, err = c.Conn.Write(b)
+ c.t.Logf("Server -> MailYak:\n%s\n", hex.Dump(b))
+ return n, err
+}
+
+func (c *connAsserts) Expect(want string) {
+ c.buf.Reset()
+
+ n, err := io.CopyN(c.buf, c, int64(len(want)))
+ if err != nil {
+ c.t.Fatalf("got error %v after reading %d bytes (got %q, want %q)", err, n, c.buf.String(), want)
+ }
+ if c.buf.String() != want {
+ c.t.Fatalf("read %q, want %q", c.buf.String(), want)
+ }
+}
+
+func (c *connAsserts) Respond(put string) {
+ n, err := c.Write([]byte(put))
+ if err != nil {
+ c.t.Fatalf("got error %v writing %q (wrote %d bytes)", err, put, n)
+ }
+}
+
+func newConnAsserts(c net.Conn, t *testing.T) *connAsserts {
+ return &connAsserts{
+ Conn: c,
+ t: t,
+ buf: &bytes.Buffer{},
+ }
+}
+
+// mockMail provides the methods for a sendableMail, allowing for deterministic
+// MIME content in tests.
+type mockMail struct {
+ localName string
+ toAddrs []string
+ fromAddr string
+ auth smtp.Auth
+ mime string
+}
+
+// getLocalName should return the sender domain to be used in the EHLO/HELO
+// command.
+func (m *mockMail) getLocalName() string {
+ return m.localName
+}
+
+// toAddrs should return a slice of email addresses to be added to the RCPT
+// TO command.
+func (m *mockMail) getToAddrs() []string {
+ return stripNames(m.toAddrs)
+}
+
+// fromAddr should return the address to be used in the MAIL FROM command.
+func (m *mockMail) getFromAddr() string {
+ return m.fromAddr
+}
+
+// auth should return the smtp.Auth if configured, nil if not.
+func (m *mockMail) getAuth() smtp.Auth {
+ return m.auth
+}
+
+// buildMime should write the generated MIME to w.
+//
+// The emailSender implementation is responsible for providing appropriate
+// buffering of writes.
+func (m *mockMail) buildMime(w io.Writer) error {
+ _, err := w.Write([]byte(m.mime))
+ return err
+}
+
+// TestSMTPProtocolExchange sends the same mock email over two different
+// transports using two different sender implementations, ensuring parity
+// between the two (specifically that both impleementations result in the same
+// SMTP conversation).
+//
+// Because the mock server in the tests does not advertise STARTTLS support in,
+// there is no upgrade.
+func TestSMTPProtocolExchange(t *testing.T) {
+ t.Parallel()
+
+ const testTimeout = 15 * time.Second
+
+ tests := []struct {
+ name string
+ mail *mockMail
+
+ // Called once the Send() method is invoked, impersonating and asserting
+ // the client/server conversation.
+ connFn func(c *connAsserts)
+
+ // Error returned when sending over TLS
+ wantTLSErr error
+
+ // Error returned when sending over plaintext
+ wantPlaintextErr error
+ }{
+ {
+ name: "ok",
+ mail: &mockMail{
+ toAddrs: []string{
+ "to@example.org",
+ "another@example.com",
+ "Dom ",
+ },
+ fromAddr: "from@example.org",
+ mime: "bananas",
+ },
+ connFn: func(c *connAsserts) {
+ c.Respond("220 localhost ESMTP bananas\r\n")
+
+ c.Expect("EHLO localhost\r\n")
+ c.Respond("250-localhost Hola\r\n")
+ c.Respond("250 AUTH LOGIN PLAIN\r\n")
+
+ c.Expect("MAIL FROM:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("DATA\r\n")
+ c.Respond("354 OK\r\n")
+ c.Expect("bananas\r\n.\r\n")
+ c.Respond("250 Will do friend\r\n")
+
+ c.Expect("QUIT\r\n")
+ c.Respond("221 Adios\r\n")
+ },
+ wantTLSErr: nil,
+ wantPlaintextErr: nil,
+ },
+ {
+ name: "with auth",
+ mail: &mockMail{
+ toAddrs: []string{
+ "to@example.org",
+ "another@example.com",
+ "dom@itsallbroken.com",
+ },
+ fromAddr: "from@example.org",
+ mime: "bananas",
+ auth: smtp.PlainAuth("ident", "user", "pass", "127.0.0.1"),
+ },
+ connFn: func(c *connAsserts) {
+ c.Respond("220 localhost ESMTP bananas\r\n")
+
+ c.Expect("EHLO localhost\r\n")
+ c.Respond("250-localhost Hola\r\n")
+ c.Respond("250 AUTH LOGIN PLAIN\r\n")
+
+ c.Expect("AUTH PLAIN aWRlbnQAdXNlcgBwYXNz\r\n")
+ c.Respond("235 Looks good\r\n")
+
+ c.Expect("MAIL FROM:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("DATA\r\n")
+ c.Respond("354 OK\r\n")
+ c.Expect("bananas\r\n.\r\n")
+ c.Respond("250 Will do friend\r\n")
+
+ c.Expect("QUIT\r\n")
+ c.Respond("221 Adios\r\n")
+ },
+ wantTLSErr: nil,
+ wantPlaintextErr: nil,
+ },
+ {
+ name: "with localname",
+ mail: &mockMail{
+ toAddrs: []string{
+ "to@example.org",
+ "another@example.com",
+ "Dom ",
+ },
+ fromAddr: "from@example.org",
+ mime: "bananas",
+ localName: "example.com",
+ },
+ connFn: func(c *connAsserts) {
+ c.Respond("220 localhost ESMTP bananas\r\n")
+
+ c.Expect("EHLO example.com\r\n")
+ c.Respond("250-example.com Hola\r\n")
+ c.Respond("250 AUTH LOGIN PLAIN\r\n")
+
+ c.Expect("MAIL FROM:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("RCPT TO:\r\n")
+ c.Respond("250 OK\r\n")
+
+ c.Expect("DATA\r\n")
+ c.Respond("354 OK\r\n")
+ c.Expect("bananas\r\n.\r\n")
+ c.Respond("250 Will do friend\r\n")
+
+ c.Expect("QUIT\r\n")
+ c.Respond("221 Adios\r\n")
+ },
+ wantTLSErr: nil,
+ wantPlaintextErr: nil,
+ },
+ }
+
+ // handleConn provides the accept loop for both the TLS server, and the
+ // plain-text server, passing the accepted connection to the test actor
+ // func.
+ //
+ // Once the actor func has finished, done is closed.
+ handleConn := func(t *testing.T, l net.Listener, done chan<- struct{}, actor func(c *connAsserts)) {
+ defer close(done)
+
+ conn, err := l.Accept()
+ if err != nil {
+ panic(err)
+ }
+ defer conn.Close()
+
+ actor(newConnAsserts(conn, t))
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Send the mock email over each implementation of the sender
+ // interface, including initialisation with the respective MailYak
+ // constructor.
+
+ t.Run("Explicit_TLS", func(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+ defer cancel()
+
+ // Initialise a server TLS config using the self-signed test
+ // certificate and key material.
+ serverConfig := &tls.Config{
+ Certificates: []tls.Certificate{
+ {
+ Certificate: [][]byte{testCertBytes},
+ PrivateKey: testRSAKey,
+ },
+ },
+ }
+
+ // Bind a TLS-enabled TCP socket to some random port
+ socket, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig)
+ if err != nil {
+ t.Fatalf("failed to bind to localhost: %v", err)
+ }
+ defer socket.Close()
+
+ handlerDone := make(chan struct{})
+ go handleConn(t, socket, handlerDone, tt.connFn)
+
+ // Build a root store for the self-signed certificate.
+ roots := x509.NewCertPool()
+ roots.AddCert(testCert)
+
+ // Initialise a TLS mailyak using the root store.
+ m, err := NewWithTLS(socket.Addr().String(), nil, &tls.Config{
+ RootCAs: roots,
+ ServerName: "127.0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Call into the sender directly, giving it the mock
+ // sendableEmail
+ sendErr := make(chan error)
+ go func() {
+ sendErr <- m.sender.Send(tt.mail)
+ }()
+
+ // Wait for the SMTP conversation to complete
+ select {
+ case <-ctx.Done():
+ t.Fatal("timeout waiting for SMTP conversation to complete")
+ case <-handlerDone:
+ // The handler is complete, wait for the send error and
+ // check it matches the expected value.
+ select {
+ case <-ctx.Done():
+ t.Fatal("timeout waiting for Send() to return")
+
+ case err := <-sendErr:
+ if !reflect.DeepEqual(err, tt.wantTLSErr) {
+ t.Errorf("got %v, want %v", err, tt.wantTLSErr)
+ }
+ }
+ }
+ })
+
+ t.Run("Plaintext", func(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+ defer cancel()
+
+ // Start listening to a local plain-text socket
+ socket, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("failed to bind to localhost: %v", err)
+ }
+ defer socket.Close()
+
+ handlerDone := make(chan struct{})
+ go handleConn(t, socket, handlerDone, tt.connFn)
+
+ m := New(socket.Addr().String(), nil)
+
+ // Call into the sender directly, giving it the mock
+ // sendableEmail
+ sendErr := make(chan error)
+ go func() {
+ sendErr <- m.sender.Send(tt.mail)
+ }()
+
+ // Wait for the SMTP conversation to complete
+ select {
+ case <-ctx.Done():
+ t.Fatal("timeout waiting for SMTP conversation to complete")
+ case <-handlerDone:
+ // The handler is complete, wait for the send error and
+ // check it matches the expected value.
+ select {
+ case <-ctx.Done():
+ t.Fatal("timeout waiting for Send() to return")
+
+ case err := <-sendErr:
+ if !reflect.DeepEqual(err, tt.wantPlaintextErr) {
+ t.Errorf("got %v, want %v", err, tt.wantPlaintextErr)
+ }
+ }
+ }
+ })
+ })
+ }
+}
diff --git a/setters.go b/setters.go
new file mode 100644
index 0000000..9c498be
--- /dev/null
+++ b/setters.go
@@ -0,0 +1,172 @@
+package mailyak
+
+import (
+ "mime"
+)
+
+// To sets a list of recipient addresses.
+//
+// You can pass one or more addresses to this method, all of which are viewable to the recipients.
+//
+// mail.To("dom@itsallbroken.com", "another@itsallbroken.com")
+//
+// or pass a slice of strings:
+//
+// tos := []string{
+// "one@itsallbroken.com",
+// "John Doe "
+// }
+//
+// mail.To(tos...)
+func (m *MailYak) To(addrs ...string) {
+ m.toAddrs = []string{}
+
+ for _, addr := range addrs {
+ trimmed := m.trimRegex.ReplaceAllString(addr, "")
+ if trimmed == "" {
+ continue
+ }
+
+ m.toAddrs = append(m.toAddrs, trimmed)
+ }
+}
+
+// Bcc sets a list of blind carbon copy (BCC) addresses.
+//
+// You can pass one or more addresses to this method, none of which are viewable to the recipients.
+//
+// mail.Bcc("dom@itsallbroken.com", "another@itsallbroken.com")
+//
+// or pass a slice of strings:
+//
+// bccs := []string{
+// "one@itsallbroken.com",
+// "John Doe "
+// }
+//
+// mail.Bcc(bccs...)
+func (m *MailYak) Bcc(addrs ...string) {
+ m.bccAddrs = []string{}
+
+ for _, addr := range addrs {
+ trimmed := m.trimRegex.ReplaceAllString(addr, "")
+ if trimmed == "" {
+ continue
+ }
+
+ m.bccAddrs = append(m.bccAddrs, trimmed)
+ }
+}
+
+// WriteBccHeader writes the BCC header to the MIME body when true. Defaults to
+// false.
+//
+// This is usually required when writing the MIME body to an email API such as
+// Amazon's SES, but can cause problems when sending emails via a SMTP server.
+//
+// Specifically, RFC822 says:
+//
+// Some systems may choose to include the text of the "Bcc" field only in the
+// author(s)'s copy, while others may also include it in the text sent to
+// all those indicated in the "Bcc" list.
+//
+// This ambiguity can result in some SMTP servers not stripping the BCC header
+// and exposing the BCC addressees to recipients. For more information, see:
+//
+// https://github.com/domodwyer/mailyak/issues/14
+func (m *MailYak) WriteBccHeader(shouldWrite bool) {
+ m.writeBccHeader = shouldWrite
+}
+
+// Cc sets a list of carbon copy (CC) addresses.
+//
+// You can pass one or more addresses to this method, which are viewable to the other recipients.
+//
+// mail.Cc("dom@itsallbroken.com", "another@itsallbroken.com")
+//
+// or pass a slice of strings:
+//
+// ccs := []string{
+// "one@itsallbroken.com",
+// "John Doe "
+// }
+//
+// mail.Cc(ccs...)
+func (m *MailYak) Cc(addrs ...string) {
+ m.ccAddrs = []string{}
+
+ for _, addr := range addrs {
+ trimmed := m.trimRegex.ReplaceAllString(addr, "")
+ if trimmed == "" {
+ continue
+ }
+
+ m.ccAddrs = append(m.ccAddrs, trimmed)
+ }
+}
+
+// From sets the sender email address.
+//
+// Users should also consider setting FromName().
+func (m *MailYak) From(addr string) {
+ m.fromAddr = m.trimRegex.ReplaceAllString(addr, "")
+}
+
+// FromName sets the sender name.
+//
+// If set, emails typically display as being from:
+//
+// From Name
+//
+// If name contains non-ASCII characters, it is Q-encoded according to RFC1342.
+func (m *MailYak) FromName(name string) {
+ m.fromName = mime.QEncoding.Encode("UTF-8", m.trimRegex.ReplaceAllString(name, ""))
+}
+
+// ReplyTo sets the Reply-To email address.
+//
+// Setting a ReplyTo address is optional.
+func (m *MailYak) ReplyTo(addr string) {
+ m.replyTo = m.trimRegex.ReplaceAllString(addr, "")
+}
+
+// Subject sets the email subject line.
+//
+// If sub contains non-ASCII characters, it is Q-encoded according to RFC1342.
+func (m *MailYak) Subject(sub string) {
+ m.subject = mime.QEncoding.Encode("UTF-8", m.trimRegex.ReplaceAllString(sub, ""))
+}
+
+// AddHeader adds an arbitrary email header.
+// It appends to any existing values associated with key.
+//
+// If value contains non-ASCII characters, it is Q-encoded according to RFC1342.
+// As always, validate any user input before adding it to a message, as this
+// method may enable an attacker to override the standard headers and, for
+// example, BCC themselves in a password reset email to a different user.
+func (m *MailYak) AddHeader(name, value string) {
+ key := m.trimRegex.ReplaceAllString(name, "")
+ m.headers[key] = append(m.headers[key], mime.QEncoding.Encode("UTF-8", m.trimRegex.ReplaceAllString(value, "")))
+}
+
+// SetHeader sets the header entries associated with key to
+// the single element value. It replaces any existing
+// values associated with key.
+//
+// If value contains non-ASCII characters, it is Q-encoded according to RFC1342.
+// As always, validate any user input before adding it to a message, as this
+// method may enable an attacker to override the standard headers and, for
+// example, BCC themselves in a password reset email to a different user.
+func (m *MailYak) SetHeader(name, value string) {
+ m.headers[m.trimRegex.ReplaceAllString(name, "")] = []string{mime.QEncoding.Encode("UTF-8", m.trimRegex.ReplaceAllString(value, ""))}
+}
+
+// LocalName sets the sender domain name.
+//
+// If set, it is used in the EHLO/HELO command instead of the default domain
+// (localhost, see [smtp.NewClient]). Some SMTP servers, such as the Gmail
+// SMTP-relay, requires a proper domain name and will reject attempts to use
+// localhost.
+func (m *MailYak) LocalName(name string) {
+ m.localName = m.trimRegex.ReplaceAllString(name, "")
+}
diff --git a/setters_test.go b/setters_test.go
new file mode 100644
index 0000000..3302ba5
--- /dev/null
+++ b/setters_test.go
@@ -0,0 +1,355 @@
+package mailyak
+
+import (
+ "reflect"
+ "regexp"
+ "testing"
+)
+
+func TestMailYakTo(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ addrs []string
+ // Want
+ want []string
+ }{
+ {
+ "Single email (without name)",
+ []string{"dom@itsallbroken.com"},
+ []string{"dom@itsallbroken.com"},
+ },
+ {
+ "Single email (with name)",
+ []string{"Dom "},
+ []string{"Dom "},
+ },
+ {
+ "Multiple email",
+ []string{"Dom ", "ohnoes@itsallbroken.com"},
+ []string{"Dom ", "ohnoes@itsallbroken.com"},
+ },
+ {
+ "Empty last",
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com", ""},
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com"},
+ },
+ {
+ "Empty Middle",
+ []string{"dom@itsallbroken.com", "", "ohnoes@itsallbroken.com"},
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com"},
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ toAddrs: []string{},
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+ m.To(tt.addrs...)
+
+ if !reflect.DeepEqual(m.toAddrs, tt.want) {
+ t.Errorf("%q. MailYak.To() = %v, want %v", tt.name, m.toAddrs, tt.want)
+ }
+ })
+ }
+}
+
+func TestMailYakBcc(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ addrs []string
+ // Want
+ want []string
+ }{
+ {
+ "Single email (without name)",
+ []string{"dom@itsallbroken.com"},
+ []string{"dom@itsallbroken.com"},
+ },
+ {
+ "Single email (with name)",
+ []string{"Dom "},
+ []string{"Dom "},
+ },
+ {
+ "Multiple email",
+ []string{"Dom ", "ohnoes@itsallbroken.com"},
+ []string{"Dom ", "ohnoes@itsallbroken.com"},
+ },
+ {
+ "Empty last",
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com", ""},
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com"},
+ },
+ {
+ "Empty Middle",
+ []string{"dom@itsallbroken.com", "", "ohnoes@itsallbroken.com"},
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com"},
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ bccAddrs: []string{},
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+ m.Bcc(tt.addrs...)
+
+ if !reflect.DeepEqual(m.bccAddrs, tt.want) {
+ t.Errorf("%q. MailYak.Bcc() = %v, want %v", tt.name, m.bccAddrs, tt.want)
+ }
+ })
+ }
+}
+
+func TestMailYakCc(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ addrs []string
+ // Want
+ want []string
+ }{
+ {
+ "Single email (without name)",
+ []string{"dom@itsallbroken.com"},
+ []string{"dom@itsallbroken.com"},
+ },
+ {
+ "Single email (with name)",
+ []string{"Dom "},
+ []string{"Dom "},
+ },
+ {
+ "Multiple email",
+ []string{"Dom ", "ohnoes@itsallbroken.com"},
+ []string{"Dom ", "ohnoes@itsallbroken.com"},
+ },
+ {
+ "Empty last",
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com", ""},
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com"},
+ },
+ {
+ "Empty Middle",
+ []string{"dom@itsallbroken.com", "", "ohnoes@itsallbroken.com"},
+ []string{"dom@itsallbroken.com", "ohnoes@itsallbroken.com"},
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ ccAddrs: []string{},
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+ m.Cc(tt.addrs...)
+
+ if !reflect.DeepEqual(m.ccAddrs, tt.want) {
+ t.Errorf("%q. MailYak.Cc() = %v, want %v", tt.name, m.ccAddrs, tt.want)
+ }
+ })
+ }
+}
+
+func TestMailYakSubject(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ subject string
+ // Want
+ want string
+ }{
+ {
+ "ASCII",
+ "Banana\r\n",
+ "Banana",
+ },
+ {
+ "Q-encoded",
+ "đ\r\n",
+ "=?UTF-8?q?=F0=9F=8D=8C?=",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+ m.Subject(tt.subject)
+
+ if !reflect.DeepEqual(m.subject, tt.want) {
+ t.Errorf("%q. MailYak.Subject() = %v, want %v", tt.name, m.subject, tt.want)
+ }
+ })
+ }
+}
+
+func TestMailYakFromName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ from string
+ // Want
+ want string
+ }{
+ {
+ "ASCII",
+ "Goat\r\n",
+ "Goat",
+ },
+ {
+ "Q-encoded",
+ "đ",
+ "=?UTF-8?q?=F0=9F=90=90?=",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+ m.FromName(tt.from)
+
+ if !reflect.DeepEqual(m.fromName, tt.want) {
+ t.Errorf("%q. MailYak.Subject() = %v, want %v", tt.name, m.fromName, tt.want)
+ }
+ })
+ }
+}
+
+func TestMailYakAddHeader(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ from map[string][]string
+ // Want
+ want map[string][]string
+ }{
+ {
+ "ASCII",
+ map[string][]string{
+ "List-Unsubscribe": {"http://example.com"},
+ "X-NASTY": {"true\r\nBcc: badguy@example.com"},
+ },
+ map[string][]string{
+ "List-Unsubscribe": {"http://example.com"},
+ "X-NASTY": {"trueBcc: badguy@example.com"},
+ },
+ },
+ {
+ "Q-encoded",
+ map[string][]string{
+ "X-BEETHOVEN": {"fĂźr Elise"},
+ },
+ map[string][]string{
+ "X-BEETHOVEN": {"=?UTF-8?q?f=C3=BCr_Elise?="},
+ },
+ },
+ {
+ "Multi",
+ map[string][]string{
+ "X-MailGun-Tag": {"marketing", "transactional"},
+ },
+ map[string][]string{
+ "X-MailGun-Tag": {"marketing", "transactional"},
+ },
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ headers: map[string][]string{},
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+
+ for k, values := range tt.from {
+ for _, v := range values {
+ m.AddHeader(k, v)
+ }
+ }
+
+ if !reflect.DeepEqual(m.headers, tt.want) {
+ t.Errorf("%q. MailYak.AddHeader() = %v, want %v", tt.name, m.headers, tt.want)
+ }
+ })
+ }
+}
+
+func TestMailYakLocalName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ // Test description.
+ name string
+ // Parameters.
+ from string
+ // Want
+ want string
+ }{
+ {
+ "empty",
+ "",
+ "",
+ },
+ {
+ "ASCII",
+ "example.com\r\n",
+ "example.com",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ m := &MailYak{
+ headers: map[string][]string{},
+ trimRegex: regexp.MustCompile("\r?\n"),
+ }
+
+ m.LocalName(tt.from)
+
+ if !reflect.DeepEqual(m.localName, tt.want) {
+ t.Errorf("%q. MailYak.LocalName() = %v, want %v", tt.name, m.localName, tt.want)
+ }
+ })
+ }
+}
diff --git a/splitter.go b/splitter.go
new file mode 100644
index 0000000..fcc8a8b
--- /dev/null
+++ b/splitter.go
@@ -0,0 +1,65 @@
+package mailyak
+
+import (
+ "io"
+)
+
+const maxLineLen = 60
+
+// lineSplitter breaks the given input into lines of maxLineLen characters
+// before writing a "\r\n" newline
+type lineSplitter struct {
+ w io.Writer
+ wrote uint
+ maxLen int
+}
+
+type lineSplitterBuilder struct{}
+
+func (b lineSplitterBuilder) new(w io.Writer) io.Writer {
+ return &lineSplitter{w: w, maxLen: maxLineLen}
+}
+
+func (w *lineSplitter) Write(p []byte) (int, error) {
+ // Calculate the previously wrote line length
+ leftover := w.wrote % uint(w.maxLen)
+
+ // Calculate the amount of bytes remaining for this line
+ lineSize := w.maxLen - int(leftover)
+
+ // Break p into chunks
+ for i := 0; i < len(p); i += lineSize {
+
+ // Reset linesize
+ if i%w.maxLen != 0 && lineSize < w.maxLen {
+ lineSize = w.maxLen
+ }
+
+ // Calculate the end of the chunk offset
+ end := i + lineSize
+ if end > len(p) {
+ end = len(p)
+ }
+
+ // Slice chunk out of p
+ chunk := p[i:end]
+ // Increment the amount wrote so far by the chunk size
+ w.wrote += uint(len(chunk))
+
+ // Write the chunk
+ if n, err := w.w.Write(chunk); err != nil {
+ return i + n, err
+ }
+
+ // If this finishes a line, add linebreaks
+ if end == i+lineSize {
+ if _, err := w.w.Write([]byte("\r\n")); err != nil {
+ // If this errors, return the bytes wrote so far from the
+ // caller's perspective (it is unaware newlines are being added)
+ return i + len(chunk), err
+ }
+ }
+ }
+
+ return len(p), nil
+}
diff --git a/splitter_test.go b/splitter_test.go
new file mode 100644
index 0000000..2dce972
--- /dev/null
+++ b/splitter_test.go
@@ -0,0 +1,224 @@
+package mailyak
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/base64"
+ "testing"
+)
+
+// TestLineSplitterWrite ensures various length data is correctly broken up
+func TestLineSplitterWrite(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ // Parameters.
+ p []byte
+ // Expected results.
+ want string
+ }{
+ {
+ "test_archive.zip",
+ []byte{0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x09, 0x00, 0x08, 0x00, 0x66, 0x6d, 0xcf, 0x48, 0xb4, 0xf8,
+ 0x71, 0xdd, 0x53, 0x01, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x00, 0x2d, 0x00, 0x1c, 0x00, 0x64, 0x65,
+ 0x61, 0x6c, 0x5f, 0x36, 0x39, 0x30, 0x30, 0x32, 0x34, 0x5f, 0x32, 0x30, 0x31, 0x36, 0x2d, 0x30,
+ 0x36, 0x2d, 0x31, 0x35, 0x2d, 0x31, 0x33, 0x34, 0x33, 0x5f, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74,
+ 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x2e, 0x63, 0x73, 0x76, 0x55, 0x54, 0x09, 0x00, 0x03,
+ 0x5f, 0x4d, 0x61, 0x57, 0x5e, 0x4d, 0x61, 0x57, 0x75, 0x78, 0x0b, 0x00, 0x01, 0x04, 0xe8, 0x03,
+ 0x00, 0x00, 0x04, 0xe8, 0x03, 0x00, 0x00, 0xce, 0xcf, 0x3e, 0x09, 0x39, 0x2c, 0x41, 0x4e, 0x46,
+ 0x01, 0xc6, 0x5c, 0x96, 0xd7, 0x7a, 0x5a, 0x3c, 0xf7, 0xa7, 0x4a, 0xfa, 0x62, 0xed, 0xc8, 0x34,
+ 0x14, 0xff, 0x69, 0x13, 0x1c, 0xb6, 0xe2, 0x97, 0x94, 0xf9, 0xbe, 0x4b, 0x37, 0x52, 0x62, 0x45,
+ 0xe0, 0xbf, 0xda, 0xd7, 0x5a, 0xe9, 0xee, 0xe1, 0x2f, 0x33, 0x9e, 0x1e, 0xc9, 0x99, 0x48, 0x80,
+ 0x4b, 0x15, 0xf5, 0x61, 0x5a, 0x21, 0x66, 0xce, 0x5f, 0x1c, 0x6a, 0xbb, 0x91, 0x65, 0xa3, 0x0f,
+ 0xeb, 0x5f, 0xc4, 0xa8, 0x9f, 0x82, 0x11, 0x1d, 0xf2, 0x9c, 0x9d, 0x94, 0x1f, 0xbd, 0x80, 0x1c,
+ 0x8a, 0xa5, 0x80, 0xae, 0x3f, 0x40, 0x50, 0x88, 0x4b, 0x5b, 0x67, 0x75, 0xc6, 0x9e, 0x6d, 0x23,
+ 0x84, 0xe2, 0xa2, 0x79, 0x69, 0x61, 0xc4, 0x03, 0x1a, 0xc4, 0xc4, 0x4b, 0xf9, 0xbe, 0xe1, 0x5e,
+ 0xe1, 0xd8, 0xb0, 0xf5, 0x1e, 0xc8, 0xd6, 0xb0, 0x34, 0x22, 0x87, 0xac, 0x65, 0xa7, 0x0a, 0x73,
+ 0x72, 0x5e, 0x33, 0x75, 0x81, 0xef, 0x7e, 0x91, 0xdf, 0x04, 0x17, 0x90, 0xeb, 0xa9, 0xfa, 0x92,
+ 0x5e, 0xb4, 0x0f, 0xe3, 0x0a, 0x68, 0x83, 0xc7, 0xc7, 0x75, 0x3a, 0xb4, 0xb7, 0x81, 0x17, 0x5f,
+ 0x27, 0xae, 0xe0, 0x5b, 0x0a, 0x90, 0xad, 0x6e, 0x8c, 0xc6, 0x01, 0x0a, 0xb7, 0xe7, 0xba, 0xfd,
+ 0x1d, 0x7f, 0x07, 0xe1, 0xd8, 0xe6, 0x61, 0x33, 0x22, 0x63, 0xe3, 0x70, 0x49, 0xd3, 0x70, 0x4d,
+ 0x11, 0x06, 0x05, 0x32, 0xd9, 0x5e, 0xfd, 0x72, 0x64, 0xef, 0x5e, 0xf8, 0xd4, 0x98, 0xa6, 0xe8,
+ 0xe1, 0x6f, 0x87, 0xd5, 0x05, 0x96, 0xf3, 0x3a, 0x60, 0x87, 0x7e, 0x94, 0x69, 0xcd, 0x69, 0x7f,
+ 0x8b, 0x8e, 0xbb, 0x2d, 0xeb, 0xa1, 0x86, 0x2f, 0xe9, 0x6d, 0x87, 0x36, 0x2e, 0xe4, 0xe0, 0xec,
+ 0x68, 0x70, 0x8e, 0x7e, 0x26, 0xd7, 0x73, 0xf7, 0x07, 0xb9, 0x5c, 0xa0, 0x08, 0x51, 0xc9, 0x50,
+ 0x7c, 0xb0, 0xef, 0xad, 0x8a, 0x0d, 0x3d, 0x5d, 0x6a, 0x7d, 0x6c, 0x59, 0x36, 0x53, 0x04, 0xaa,
+ 0x5b, 0x2e, 0x63, 0x5b, 0xd5, 0x00, 0x06, 0x84, 0xbc, 0x6c, 0x3e, 0xf5, 0xc7, 0x52, 0x1d, 0x48,
+ 0xc5, 0x61, 0x1a, 0x69, 0x2f, 0xba, 0x83, 0x34, 0xe1, 0xda, 0xb3, 0x3f, 0xd6, 0x31, 0x89, 0xd2,
+ 0x10, 0xb2, 0xba, 0x7e, 0x9d, 0xab, 0x4b, 0xf7, 0x33, 0xc5, 0x06, 0x5e, 0x91, 0x5f, 0xa6, 0xfb,
+ 0xe3, 0x38, 0xbd, 0x39, 0x80, 0xf5, 0xb2, 0x4b, 0x6d, 0xdb, 0x50, 0x4b, 0x07, 0x08, 0xb4, 0xf8,
+ 0x71, 0xdd, 0x53, 0x01, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x00, 0x50, 0x4b, 0x01, 0x02, 0x1e, 0x03,
+ 0x14, 0x00, 0x09, 0x00, 0x08, 0x00, 0x66, 0x6d, 0xcf, 0x48, 0xb4, 0xf8, 0x71, 0xdd, 0x53, 0x01,
+ 0x00, 0x00, 0xd0, 0x01, 0x00, 0x00, 0x2d, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
+ 0x00, 0x00, 0xb4, 0x81, 0x00, 0x00, 0x00, 0x00, 0x64, 0x65, 0x61, 0x6c, 0x5f, 0x36, 0x39, 0x30,
+ 0x30, 0x32, 0x34, 0x5f, 0x32, 0x30, 0x31, 0x36, 0x2d, 0x30, 0x36, 0x2d, 0x31, 0x35, 0x2d, 0x31,
+ 0x33, 0x34, 0x33, 0x5f, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72,
+ 0x73, 0x2e, 0x63, 0x73, 0x76, 0x55, 0x54, 0x05, 0x00, 0x03, 0x5f, 0x4d, 0x61, 0x57, 0x75, 0x78,
+ 0x0b, 0x00, 0x01, 0x04, 0xe8, 0x03, 0x00, 0x00, 0x04, 0xe8, 0x03, 0x00, 0x00, 0x50, 0x4b, 0x05,
+ 0x06, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x73, 0x00, 0x00, 0x00, 0xca, 0x01, 0x00,
+ 0x00, 0x00, 0x00,
+ },
+ "UEsDBBQACQAIAGZtz0i0+HHdUwEAANABAAAtABwAZGVhbF82OTAwMjRfMjAx\r\n" +
+ "Ni0wNi0xNS0xMzQzX2xhdGVzdF9vcmRlcnMuY3N2VVQJAANfTWFXXk1hV3V4\r\n" +
+ "CwABBOgDAAAE6AMAAM7PPgk5LEFORgHGXJbXelo896dK+mLtyDQU/2kTHLbi\r\n" +
+ "l5T5vks3UmJF4L/a11rp7uEvM54eyZlIgEsV9WFaIWbOXxxqu5Flow/rX8So\r\n" +
+ "n4IRHfKcnZQfvYAciqWArj9AUIhLW2d1xp5tI4TionlpYcQDGsTES/m+4V7h\r\n" +
+ "2LD1HsjWsDQih6xlpwpzcl4zdYHvfpHfBBeQ66n6kl60D+MKaIPHx3U6tLeB\r\n" +
+ "F18nruBbCpCtbozGAQq357r9HX8H4djmYTMiY+NwSdNwTREGBTLZXv1yZO9e\r\n" +
+ "+NSYpujhb4fVBZbzOmCHfpRpzWl/i467Leuhhi/pbYc2LuTg7Ghwjn4m13P3\r\n" +
+ "B7lcoAhRyVB8sO+tig09XWp9bFk2UwSqWy5jW9UABoS8bD71x1IdSMVhGmkv\r\n" +
+ "uoM04dqzP9YxidIQsrp+natL9zPFBl6RX6b74zi9OYD1sktt21BLBwi0+HHd\r\n" +
+ "UwEAANABAABQSwECHgMUAAkACABmbc9ItPhx3VMBAADQAQAALQAYAAAAAAAB\r\n" +
+ "AAAAtIEAAAAAZGVhbF82OTAwMjRfMjAxNi0wNi0xNS0xMzQzX2xhdGVzdF9v\r\n" +
+ "cmRlcnMuY3N2VVQFAANfTWFXdXgLAAEE6AMAAAToAwAAUEsFBgAAAAABAAEA\r\n" +
+ "cwAAAMoBAAAAAA==",
+ },
+ {
+ "test",
+ []byte("test"),
+ "dGVzdA==",
+ },
+ {
+ "sentance",
+ []byte("A man may fight for many things. His country, his principles, his friends. " +
+ "The glistening tear on the cheek of a golden child. But personally, I'd mud-wrestle " +
+ "my own mother for a ton of cash, an amusing clock and a sack of French porn."),
+ "QSBtYW4gbWF5IGZpZ2h0IGZvciBtYW55IHRoaW5ncy4gSGlzIGNvdW50cnks\r\n" +
+ "IGhpcyBwcmluY2lwbGVzLCBoaXMgZnJpZW5kcy4gVGhlIGdsaXN0ZW5pbmcg\r\n" +
+ "dGVhciBvbiB0aGUgY2hlZWsgb2YgYSBnb2xkZW4gY2hpbGQuIEJ1dCBwZXJz\r\n" +
+ "b25hbGx5LCBJJ2QgbXVkLXdyZXN0bGUgbXkgb3duIG1vdGhlciBmb3IgYSB0\r\n" +
+ "b24gb2YgY2FzaCwgYW4gYW11c2luZyBjbG9jayBhbmQgYSBzYWNrIG9mIEZy\r\n" +
+ "ZW5jaCBwb3JuLg==",
+ },
+ {
+ "test.png",
+ []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+ 0x00, 0x00, 0x01, 0x2c, 0x00, 0x00, 0x01, 0x2c, 0x08, 0x02, 0x00, 0x00, 0x00, 0xf6, 0x1f, 0x19,
+ 0x22, 0x00, 0x00, 0x03, 0x97, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0xed, 0xd9, 0x31, 0x8a, 0xc3,
+ 0x40, 0x14, 0x44, 0xc1, 0x1e, 0xe3, 0xfb, 0x5f, 0x59, 0x8a, 0x9d, 0x09, 0x1c, 0xbc, 0x40, 0x55,
+ 0x6c, 0xb4, 0x20, 0x70, 0xf2, 0x68, 0x98, 0x7f, 0xb6, 0x6b, 0xbb, 0xce, 0xef, 0xdf, 0xb6, 0xf3,
+ 0xe8, 0x9f, 0xf3, 0xad, 0x6f, 0x7d, 0xfb, 0xe7, 0xb7, 0x9f, 0x01, 0xa9, 0xef, 0x4e, 0xfd, 0x13,
+ 0xe0, 0xdd, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0xbc, 0x8e, 0x42,
+ 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84,
+ 0x10, 0x73, 0xa2, 0x80, 0x98, 0x25, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62,
+ 0x22, 0x84, 0x98, 0x08, 0x21, 0xe6, 0x44, 0x01, 0x31, 0x4b, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84,
+ 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0xaf, 0xa3, 0x10, 0xb3, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x9c, 0x28, 0x20, 0x66, 0x09,
+ 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x39,
+ 0x51, 0x40, 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42,
+ 0xcc, 0xeb, 0x28, 0xc4, 0x2c, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13,
+ 0x21, 0xc4, 0x44, 0x08, 0x31, 0x27, 0x0a, 0x88, 0x59, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84,
+ 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x4e, 0x14, 0x10, 0xb3, 0x84, 0x10, 0x13,
+ 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0xf3, 0x3a, 0x0a, 0x31, 0x4b, 0x08,
+ 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0xcc, 0x89,
+ 0x02, 0x62, 0x96, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62,
+ 0x22, 0x84, 0x98, 0x13, 0x05, 0xc4, 0x2c, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84,
+ 0x10, 0x13, 0x21, 0xc4, 0xbc, 0x8e, 0x42, 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x73, 0xa2, 0x80, 0x98, 0x25, 0x84, 0x98, 0x08,
+ 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0xe6, 0x44, 0x01, 0x31,
+ 0x4b, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0xaf, 0xa3,
+ 0x10, 0xb3, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13,
+ 0x21, 0xc4, 0x9c, 0x28, 0x20, 0x66, 0x09, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84,
+ 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x39, 0x51, 0x40, 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13,
+ 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0xcc, 0xeb, 0x28, 0xc4, 0x2c, 0x21, 0xc4, 0x44, 0x08,
+ 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x27, 0x0a, 0x88, 0x59,
+ 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62,
+ 0x4e, 0x14, 0x10, 0xb3, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84,
+ 0x10, 0xf3, 0x3a, 0x0a, 0x31, 0x4b, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x11, 0x42, 0xcc, 0x89, 0x02, 0x62, 0x96, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08,
+ 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x13, 0x05, 0xc4, 0x2c, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0xbc, 0x8e, 0x42, 0xcc, 0x12,
+ 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x73,
+ 0xa2, 0x80, 0x98, 0x25, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84,
+ 0x98, 0x08, 0x21, 0xe6, 0x44, 0x01, 0x31, 0x4b, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13,
+ 0x21, 0xc4, 0x44, 0x08, 0x31, 0xaf, 0xa3, 0x10, 0xb3, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08,
+ 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x9c, 0x28, 0x20, 0x66, 0x09, 0x21, 0x26,
+ 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x39, 0x51, 0x40,
+ 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0xcc, 0xeb,
+ 0x28, 0xc4, 0x2c, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x27, 0x0a, 0x88, 0x59, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08,
+ 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x4e, 0x14, 0x10, 0xb3, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0xf3, 0x3a, 0x0a, 0x31, 0x4b, 0x08, 0x31, 0x11,
+ 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0xcc, 0x89, 0x02, 0x62,
+ 0x96, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84,
+ 0x98, 0x13, 0x05, 0xc4, 0x2c, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13,
+ 0x21, 0xc4, 0xbc, 0x8e, 0x42, 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08,
+ 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x73, 0xa2, 0x80, 0x98, 0x25, 0x84, 0x98, 0x08, 0x21, 0x26,
+ 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0xe6, 0x44, 0x01, 0x31, 0x4b, 0x08,
+ 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0xaf, 0xa3, 0x10, 0xb3,
+ 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x9c, 0x28, 0x20, 0x66, 0x09, 0x21, 0x26, 0x42, 0x88, 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08,
+ 0x21, 0x26, 0x42, 0x88, 0x39, 0x51, 0x40, 0xcc, 0x12, 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4,
+ 0x44, 0x08, 0x31, 0x11, 0x42, 0xcc, 0xeb, 0x28, 0xc4, 0x2c, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x11,
+ 0x42, 0x4c, 0x84, 0x10, 0x13, 0x21, 0xc4, 0x44, 0x08, 0x31, 0x27, 0x0a, 0x88, 0x59, 0x42, 0x88,
+ 0x89, 0x10, 0x62, 0x22, 0x84, 0x98, 0x08, 0x21, 0x26, 0x42, 0x88, 0xdd, 0x07, 0xb4, 0x05, 0x5f,
+ 0x21, 0xcb, 0x54, 0xd1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+ },
+ "iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAIAAAD2HxkiAAADl0lEQVR4nO3Z\r\n" +
+ "MYrDQBREwR7j+19Zip0JHLxAVWy0IHDyaJh/tmu7zu/ftvPon/Otb33757ef\r\n" +
+ "AanvTv0T4N1ECDERQkyEEBMhxLyOQswSQkyEEBMhxEQIMRFCTIQQc6KAmCWE\r\n" +
+ "mAghJkKIiRBiIoSYCCHmRAExSwgxEUJMhBATIcRECDGvoxCzhBATIcRECDER\r\n" +
+ "QkyEEBMhxJwoIGYJISZCiIkQYiKEmAghJkKIOVFAzBJCTIQQEyHERAgxEULM\r\n" +
+ "6yjELCHERAgxEUJMhBATIcRECDEnCohZQoiJEGIihJgIISZCiIkQYk4UELOE\r\n" +
+ "EBMhxEQIMRFCTIQQ8zoKMUsIMRFCTIQQEyHERAgxEULMiQJilhBiIoSYCCEm\r\n" +
+ "QoiJEGIihJgTBcQsIcRECDERQkyEEBMhxLyOQswSQkyEEBMhxEQIMRFCTIQQ\r\n" +
+ "c6KAmCWEmAghJkKIiRBiIoSYCCHmRAExSwgxEUJMhBATIcRECDGvoxCzhBAT\r\n" +
+ "IcRECDERQkyEEBMhxJwoIGYJISZCiIkQYiKEmAghJkKIOVFAzBJCTIQQEyHE\r\n" +
+ "RAgxEULM6yjELCHERAgxEUJMhBATIcRECDEnCohZQoiJEGIihJgIISZCiIkQ\r\n" +
+ "Yk4UELOEEBMhxEQIMRFCTIQQ8zoKMUsIMRFCTIQQEyHERAgxEULMiQJilhBi\r\n" +
+ "IoSYCCEmQoiJEGIihJgTBcQsIcRECDERQkyEEBMhxLyOQswSQkyEEBMhxEQI\r\n" +
+ "MRFCTIQQc6KAmCWEmAghJkKIiRBiIoSYCCHmRAExSwgxEUJMhBATIcRECDGv\r\n" +
+ "oxCzhBATIcRECDERQkyEEBMhxJwoIGYJISZCiIkQYiKEmAghJkKIOVFAzBJC\r\n" +
+ "TIQQEyHERAgxEULM6yjELCHERAgxEUJMhBATIcRECDEnCohZQoiJEGIihJgI\r\n" +
+ "ISZCiIkQYk4UELOEEBMhxEQIMRFCTIQQ8zoKMUsIMRFCTIQQEyHERAgxEULM\r\n" +
+ "iQJilhBiIoSYCCEmQoiJEGIihJgTBcQsIcRECDERQkyEEBMhxLyOQswSQkyE\r\n" +
+ "EBMhxEQIMRFCTIQQc6KAmCWEmAghJkKIiRBiIoSYCCHmRAExSwgxEUJMhBAT\r\n" +
+ "IcRECDGvoxCzhBATIcRECDERQkyEEBMhxJwoIGYJISZCiIkQYiKEmAghJkKI\r\n" +
+ "OVFAzBJCTIQQEyHERAgxEULM6yjELCHERAgxEUJMhBATIcRECDEnCohZQoiJ\r\n" +
+ "EGIihJgIISZCiN0HtAVfIctU0QAAAABJRU5ErkJggg==",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+
+ w := &lineSplitter{w: &buf, maxLen: maxLineLen}
+
+ encoder := base64.NewEncoder(base64.StdEncoding, w)
+ _, err := encoder.Write(tt.p)
+ encoder.Close()
+
+ if err != nil {
+ t.Fatalf("%q. base64LineWriter.Write() error = %v", tt.name, err)
+ }
+
+ if buf.String() != tt.want {
+ t.Errorf("%q. base64LineWriter.Write() = \n%v\n, want \n%v\n", tt.name, buf.String(), tt.want)
+ }
+ })
+ }
+}
+
+func TestChunkedWrites(t *testing.T) {
+ s := "a 21 character string"
+
+ var buf bytes.Buffer
+ w := &lineSplitter{w: &buf, maxLen: maxLineLen}
+
+ for i := 0; i < 20; i++ {
+ if n, err := w.Write([]byte(s)); n != len(s) || err != nil {
+ t.Fatalf("wrote %d, err %v", n, err)
+ }
+ }
+
+ scanner := bufio.NewScanner(&buf)
+ for scanner.Scan() {
+ if len(scanner.Text()) > maxLineLen {
+ t.Errorf("got linelength = %d want <= %d\n", len(scanner.Text()), maxLineLen)
+ }
+ }
+}
diff --git a/writer.go b/writer.go
new file mode 100644
index 0000000..eac91fc
--- /dev/null
+++ b/writer.go
@@ -0,0 +1,13 @@
+package mailyak
+
+import "bytes"
+
+// BodyPart is a buffer holding the contents of an email MIME part.
+type BodyPart struct{ bytes.Buffer }
+
+// Set accepts a string s as the contents of a BodyPart, replacing any existing
+// data.
+func (w *BodyPart) Set(s string) {
+ w.Reset()
+ w.WriteString(s)
+}
diff --git a/writer_test.go b/writer_test.go
new file mode 100644
index 0000000..b563d15
--- /dev/null
+++ b/writer_test.go
@@ -0,0 +1,161 @@
+package mailyak
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/smtp"
+ "strings"
+ "testing"
+)
+
+// TestHTML ensures we can write to HTML as an io.Writer
+func TestHTML(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ // Parameters.
+ data []string
+ }{
+ {
+ "Writer test",
+ []string{"Worst idea since someone said âyeah letâs take this suspiciously large " +
+ "wooden horse into Troy, statues are all the rage this seasonâ.",
+ },
+ },
+ {
+ "Writer test multiple",
+ []string{
+ "Worst idea since someone said âyeah letâs take this suspiciously large " +
+ "wooden horse into Troy, statues are all the rage this seasonâ.",
+ "Am I jumping the gun, Baldrick, or are the words 'I have a cunning plan' " +
+ "marching with ill-deserved confidence in the direction of this conversation?",
+ },
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ mail := New("", smtp.PlainAuth("", "", "", ""))
+
+ for _, data := range tt.data {
+ if _, err := io.WriteString(mail.HTML(), data); err != nil {
+ t.Errorf("%q. HTML() error = %v", tt.name, err)
+ continue
+ }
+ }
+
+ if !bytes.Equal([]byte(strings.Join(tt.data, "")), mail.html.Bytes()) {
+ t.Errorf("%q. HTML() = %v, want %v", tt.name, mail.html.String(), tt.data)
+ }
+ })
+ }
+}
+
+// TestPlain ensures we can write to Plain as an io.Writer
+func TestPlain(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ // Parameters.
+ data string
+ }{
+ {
+ "Writer test",
+ "Am I jumping the gun, Baldrick, or are the words 'I have a cunning plan' " +
+ "marching with ill-deserved confidence in the direction of this conversation?",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ mail := New("", smtp.PlainAuth("", "", "", ""))
+
+ if _, err := io.WriteString(mail.Plain(), tt.data); err != nil {
+ t.Fatalf("%q. Plain() error = %v", tt.name, err)
+ }
+
+ if !bytes.Equal([]byte(tt.data), mail.plain.Bytes()) {
+ t.Errorf("%q. Plain() = %v, want %v", tt.name, mail.plain.String(), tt.data)
+ }
+ })
+ }
+}
+
+// TestWritableString ensures the writable type returns a string when called
+// with fmt.Printx(), etc
+func TestWritableString(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ // Parameters.
+ data string
+ }{
+ {
+ "String test",
+ "Baldrick, does it have to be this way? " +
+ "Our valued friendship ending with me cutting you up into strips and telling " +
+ "the prince that you walked over a very sharp cattle grid in an extremely heavy hat?",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ mail := New("", smtp.PlainAuth("", "", "", ""))
+
+ if _, err := io.WriteString(mail.Plain(), tt.data); err != nil {
+ t.Fatalf("%q. Plain() error = %v", tt.name, err)
+ }
+
+ if tt.data != mail.plain.String() {
+ t.Errorf("%q. writable.String() = %v, want %v", tt.name, mail.plain.String(), tt.data)
+ }
+
+ if out := fmt.Sprintf("%v", mail.plain.String()); out != tt.data {
+ t.Errorf("%q. writable.String() via fmt.Sprintf = %v, want %v", tt.name, out, tt.data)
+ }
+ })
+ }
+}
+
+// TestPlain_String ensures we can use the string setter
+func TestPlain_String(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ // Parameters.
+ data string
+ }{
+ {
+ "Writer test",
+ "Am I jumping the gun, Baldrick, or are the words 'I have a cunning plan' " +
+ "marching with ill-deserved confidence in the direction of this conversation?",
+ },
+ }
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ mail := New("", smtp.PlainAuth("", "", "", ""))
+
+ if _, err := io.WriteString(mail.Plain(), tt.data); err != nil {
+ t.Fatalf("%q. Plain() error = %v", tt.name, err)
+ }
+
+ if !bytes.Equal([]byte(tt.data), mail.plain.Bytes()) {
+ t.Errorf("%q. Plain() = %v, want %v", tt.name, mail.plain.String(), tt.data)
+ }
+ })
+ }
+}