Adding upstream version 3.6.2.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
1347af6294
commit
cb9cbb7a25
32 changed files with 4944 additions and 0 deletions
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 99
|
||||
reviewers:
|
||||
- domodwyer
|
||||
assignees:
|
||||
- domodwyer
|
40
.github/pull_request_template.md
vendored
Normal file
40
.github/pull_request_template.md
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
<!--
|
||||
|
||||
Thanks for opening a PR! Seriously, it's awesome, thank you!
|
||||
|
||||
To make sure this is as easy as possible, please make sure:
|
||||
|
||||
* There are no breaking changes
|
||||
* Your change has tests
|
||||
* Existing tests pass
|
||||
* You've run the static analysis lints!
|
||||
|
||||
The lints run as part of the CI process so they need to pass before the PR can
|
||||
be merged. If you want to run them locally too see below.
|
||||
|
||||
Please delete this template and write your PR message :)
|
||||
|
||||
Thanks again!
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
If you want to run the lints locally, they are executed as pre-commit hooks and
|
||||
are managed with https://pre-commit.com
|
||||
|
||||
Once pre-commit is installed, add the hooks to this repo:
|
||||
|
||||
```bash
|
||||
dom:mailyak% pre-commit install -t pre-commit
|
||||
dom:mailyak% pre-commit install -t pre-push
|
||||
dom:mailyak% pre-commit install -t post-checkout
|
||||
```
|
||||
|
||||
They will then check any commits you make from now on :)
|
||||
|
||||
If you want to quickly run them without committing something, just run
|
||||
`pre-commit run --all`
|
||||
|
||||
-->
|
37
.github/workflows/go-test.yml
vendored
Normal file
37
.github/workflows/go-test.yml
vendored
Normal file
|
@ -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
|
25
.github/workflows/lints.yml
vendored
Normal file
25
.github/workflows/lints.yml
vendored
Normal file
|
@ -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
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
@ -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
|
64
.golangci.yml
Normal file
64
.golangci.yml
Normal file
|
@ -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"
|
52
.pre-commit-config.yaml
Normal file
52
.pre-commit-config.yaml
Normal file
|
@ -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]+"]
|
7
.travis.yml
Normal file
7
.travis.yml
Normal file
|
@ -0,0 +1,7 @@
|
|||
language: go
|
||||
|
||||
go:
|
||||
- "1.x"
|
||||
- 1.8
|
||||
- 1.9
|
||||
- "1.10.x"
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -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.
|
103
README.md
Normal file
103
README.md
Normal file
|
@ -0,0 +1,103 @@
|
|||
[](https://travis-ci.org/domodwyer/mailyak)
|
||||
[](https://godoc.org/github.com/domodwyer/mailyak)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://s3-eu-west-1.amazonaws.com/iab-assets/mailyak-header.png" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>An elegant MIME mail library with support for attachments</em>
|
||||
</p>
|
||||
<br /><br /><br />
|
||||
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.
|
166
attachments.go
Normal file
166
attachments.go
Normal file
|
@ -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:
|
||||
//
|
||||
// <img src="cid:myFileName"/>
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// <img src="cid:myFileName"/>
|
||||
//
|
||||
// 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
|
||||
}
|
1025
attachments_test.go
Normal file
1025
attachments_test.go
Normal file
File diff suppressed because it is too large
Load diff
176
examples_test.go
Normal file
176
examples_test.go
Normal file
|
@ -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(`
|
||||
<html>
|
||||
<body>
|
||||
<img src="cid:myimage"/>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
// Send it!
|
||||
if err := mail.Send(); err != nil {
|
||||
panic(" :( ")
|
||||
}
|
||||
}
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module github.com/domodwyer/mailyak/v3
|
||||
|
||||
go 1.12
|
46
int-test/cert.pem
Normal file
46
int-test/cert.pem
Normal file
|
@ -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-----
|
46
int-test/run.sh
Executable file
46
int-test/run.sh
Executable file
|
@ -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}")"
|
234
integration_test.go
Normal file
234
integration_test.go
Normal file
|
@ -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("<html><head></head></html>"))
|
||||
m.Attach("test2.html", strings.NewReader("<html><head></head></html>"))
|
||||
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("<html><head></head></html>"))
|
||||
m.Attach("test2.html", strings.NewReader("<html><head></head></html>"))
|
||||
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("<html><head></head></html>"))
|
||||
m.Attach("test2.html", strings.NewReader("<html><head></head></html>"))
|
||||
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("<html><head></head></html>"))
|
||||
m.Attach("test2.html", strings.NewReader("<html><head></head></html>"))
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
230
mailyak.go
Normal file
230
mailyak.go
Normal file
|
@ -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 <b@example.com>", "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
|
||||
}
|
94
mailyak_test.go
Normal file
94
mailyak_test.go
Normal file
|
@ -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("<html><head></head></html>"))
|
||||
mail.Attach("test2.html", strings.NewReader("<html><head></head></html>"))
|
||||
|
||||
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 <c@example.com>",
|
||||
"<d@example.com>",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
202
mime.go
Normal file
202
mime.go
Normal file
|
@ -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
|
||||
}
|
657
mime_test.go
Normal file
657
mime_test.go
Normal file
|
@ -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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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 <dom@itsallbroken.com>\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: <testAttachment>\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 <addr>\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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
18
package.go
Normal file
18
package.go
Normal file
|
@ -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
|
108
sender.go
Normal file
108
sender.go
Normal file
|
@ -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()
|
||||
}
|
64
sender_explicit_tls.go
Normal file
64
sender_explicit_tls.go
Normal file
|
@ -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
|
||||
}
|
47
sender_starttls.go
Normal file
47
sender_starttls.go
Normal file
|
@ -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{},
|
||||
}
|
||||
}
|
453
sender_test.go
Normal file
453
sender_test.go
Normal file
|
@ -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 <dom@itsallbroken.com>",
|
||||
},
|
||||
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:<from@example.org>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<to@example.org>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<another@example.com>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<dom@itsallbroken.com>\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:<from@example.org>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<to@example.org>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<another@example.com>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<dom@itsallbroken.com>\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 <dom@itsallbroken.com>",
|
||||
},
|
||||
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:<from@example.org>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<to@example.org>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<another@example.com>\r\n")
|
||||
c.Respond("250 OK\r\n")
|
||||
|
||||
c.Expect("RCPT TO:<dom@itsallbroken.com>\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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
172
setters.go
Normal file
172
setters.go
Normal file
|
@ -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 <two@itsallbroken.com>"
|
||||
// }
|
||||
//
|
||||
// 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 <two@itsallbroken.com>"
|
||||
// }
|
||||
//
|
||||
// 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 <two@itsallbroken.com>"
|
||||
// }
|
||||
//
|
||||
// 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 <sender@example.com>
|
||||
//
|
||||
// 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, "")
|
||||
}
|
355
setters_test.go
Normal file
355
setters_test.go
Normal file
|
@ -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 <dom@itsallbroken.com>"},
|
||||
[]string{"Dom <dom@itsallbroken.com>"},
|
||||
},
|
||||
{
|
||||
"Multiple email",
|
||||
[]string{"Dom <dom@itsallbroken.com>", "ohnoes@itsallbroken.com"},
|
||||
[]string{"Dom <dom@itsallbroken.com>", "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 <dom@itsallbroken.com>"},
|
||||
[]string{"Dom <dom@itsallbroken.com>"},
|
||||
},
|
||||
{
|
||||
"Multiple email",
|
||||
[]string{"Dom <dom@itsallbroken.com>", "ohnoes@itsallbroken.com"},
|
||||
[]string{"Dom <dom@itsallbroken.com>", "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 <dom@itsallbroken.com>"},
|
||||
[]string{"Dom <dom@itsallbroken.com>"},
|
||||
},
|
||||
{
|
||||
"Multiple email",
|
||||
[]string{"Dom <dom@itsallbroken.com>", "ohnoes@itsallbroken.com"},
|
||||
[]string{"Dom <dom@itsallbroken.com>", "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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
65
splitter.go
Normal file
65
splitter.go
Normal file
|
@ -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
|
||||
}
|
224
splitter_test.go
Normal file
224
splitter_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
13
writer.go
Normal file
13
writer.go
Normal file
|
@ -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)
|
||||
}
|
161
writer_test.go
Normal file
161
writer_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue