Repository: jhillyerd/enmime
Pull request #138 (2019-11-23 21:12:31)

Increase test coverage
Just trying to get that test coverage number up

coveralls: 
[![Coverage Status](https://coveralls.io/builds/27197801/badge)](https://coveralls.io/builds/27197801)

Coverage increased (+2.5%) to 90.671% when pulling **61f705b68d8857e2e740fc393f3bc0f4263ac48c on requaos:requaos/coverage** into **7d95f55019bca3a42df416b82acd419c0b5afe6c on jhillyerd:develop**.

requaos: Removing the latin map test because it had no effect on the cover percentage of unicode.go in coveralls
requaos: Oh yeah, green coverage badge!
jhillyerd: Amazing!  I wasn't even sure it was possible to get to 90% for us.

Patch:
diff --git a/cmd/mime-dump/mime-dump.go b/cmd/mime-dump/mime-dump.go
index 49c9bda..66e6e5d 100644
--- a/cmd/mime-dump/mime-dump.go
+++ b/cmd/mime-dump/mime-dump.go
@@ -3,6 +3,7 @@ package main
 
 import (
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 
@@ -10,28 +11,48 @@ import (
 	"github.com/jhillyerd/enmime/cmd"
 )
 
+type dumper struct {
+	errOut, stdOut io.Writer
+	exit           exitFunc
+}
+type exitFunc func(int)
+
+func newDefaultDumper() *dumper {
+	return &dumper{
+		errOut: os.Stderr,
+		stdOut: os.Stdout,
+		exit:   os.Exit,
+	}
+}
+
 func main() {
-	if len(os.Args) < 2 {
-		fmt.Fprintln(os.Stderr, "Missing filename argument")
-		os.Exit(1)
+	d := newDefaultDumper()
+	d.exit(d.dump(os.Args))
+}
+
+func (d *dumper) dump(args []string) int {
+	if len(args) < 2 {
+		fmt.Fprintln(d.errOut, "Missing filename argument")
+		return 1
 	}
 
-	reader, err := os.Open(os.Args[1])
+	reader, err := os.Open(args[1])
 	if err != nil {
-		fmt.Fprintln(os.Stderr, "Failed to open file:", err)
-		os.Exit(1)
+		fmt.Fprintln(d.errOut, "Failed to open file:", err)
+		return 1
 	}
 
 	// basename is used as the markdown title
-	basename := filepath.Base(os.Args[1])
+	basename := filepath.Base(args[1])
 	e, err := enmime.ReadEnvelope(reader)
 	if err != nil {
-		fmt.Fprintln(os.Stderr, "During enmime.ReadEnvelope:", err)
-		os.Exit(1)
+		fmt.Fprintln(d.errOut, "During enmime.ReadEnvelope:", err)
+		return 1
 	}
 
-	if err = cmd.EnvelopeToMarkdown(os.Stdout, e, basename); err != nil {
-		fmt.Fprintln(os.Stderr, err)
-		os.Exit(1)
+	if err = cmd.EnvelopeToMarkdown(d.stdOut, e, basename); err != nil {
+		fmt.Fprintln(d.errOut, err)
+		return 1
 	}
+	return 0
 }
diff --git a/cmd/mime-extractor/mime-extractor.go b/cmd/mime-extractor/mime-extractor.go
index d037eaa..2313bc4 100644
--- a/cmd/mime-extractor/mime-extractor.go
+++ b/cmd/mime-extractor/mime-extractor.go
@@ -4,6 +4,7 @@ package main
 import (
 	"flag"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -17,51 +18,77 @@ var (
 	outdir   = flag.String("o", "", "output dir")
 )
 
+type extractor struct {
+	errOut, stdOut io.Writer
+	exit           exitFunc
+	wd             workingDir
+	fileWrite      attachmentWriter
+}
+
+type exitFunc func(int)
+type workingDir func() (string, error)
+type attachmentWriter func(string, []byte, os.FileMode) error
+
+func newDefaultExtractor() *extractor {
+	return &extractor{
+		errOut:    os.Stderr,
+		stdOut:    os.Stdout,
+		exit:      os.Exit,
+		wd:        os.Getwd,
+		fileWrite: ioutil.WriteFile,
+	}
+}
+
 func main() {
 	flag.Parse()
-	if *mimefile == "" {
-		fmt.Fprintln(os.Stderr, "Missing filename argument")
+	ex := newDefaultExtractor()
+	ex.exit(ex.extract(*mimefile, *outdir))
+}
+
+func (ex *extractor) extract(file, dir string) int {
+	if file == "" {
+		fmt.Fprintln(ex.errOut, "Missing filename argument")
 		flag.Usage()
-		os.Exit(1)
+		return 1
 	}
-	cwd, _ := os.Getwd()
-	if *outdir == "" {
-		outdir = &cwd
+	if dir == "" {
+		dir, _ = ex.wd()
 	}
 
-	if err := os.MkdirAll(*outdir, os.ModePerm); err != nil {
-		fmt.Fprintf(os.Stderr, "Mkdir %s failed.", *outdir)
-		os.Exit(2)
+	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+		fmt.Fprintf(ex.errOut, "Mkdir %s failed.", dir)
+		return 2
 	}
 
-	reader, err := os.Open(*mimefile)
+	reader, err := os.Open(file)
 	if err != nil {
-		fmt.Fprintln(os.Stderr, "Failed to open file:", err)
-		os.Exit(1)
+		fmt.Fprintln(ex.errOut, "Failed to open file:", err)
+		return 1
 	}
 
 	// basename is used as the markdown title
-	basename := filepath.Base(*mimefile)
+	basename := filepath.Base(file)
 	e, err := enmime.ReadEnvelope(reader)
 	if err != nil {
-		fmt.Fprintln(os.Stderr, "During enmime.ReadEnvelope:", err)
-		os.Exit(1)
+		fmt.Fprintln(ex.errOut, "During enmime.ReadEnvelope:", err)
+		return 1
 	}
 
-	if err = cmd.EnvelopeToMarkdown(os.Stdout, e, basename); err != nil {
-		fmt.Fprintln(os.Stderr, err)
-		os.Exit(1)
+	if err = cmd.EnvelopeToMarkdown(ex.stdOut, e, basename); err != nil {
+		fmt.Fprintln(ex.errOut, err)
+		return 1
 	}
 
-	// Write out attachments
-	fmt.Fprintf(os.Stderr, "\nExtracting attachments into %s...", *outdir)
+	// Write errOut attachments
+	fmt.Fprintf(ex.errOut, "\nExtracting attachments into %s...", dir)
 	for _, a := range e.Attachments {
-		newFileName := filepath.Join(*outdir, a.FileName)
-		err = ioutil.WriteFile(newFileName, a.Content, 0644)
+		newFileName := filepath.Join(dir, a.FileName)
+		err = ex.fileWrite(newFileName, a.Content, 0644)
 		if err != nil {
-			fmt.Printf("Error writing file %q: %v\n", newFileName, err)
+			fmt.Fprintf(ex.stdOut, "Error writing file %q: %v\n", newFileName, err)
 			break
 		}
 	}
-	fmt.Fprintln(os.Stderr, " Done!")
+	fmt.Fprintln(ex.errOut, " Done!")
+	return 0
 }
diff --git a/internal/stringutil/unicode.go b/internal/stringutil/unicode.go
index 9a39c24..93a7f89 100644
--- a/internal/stringutil/unicode.go
+++ b/internal/stringutil/unicode.go
@@ -8,33 +8,25 @@ import (
 	"golang.org/x/text/unicode/norm"
 )
 
+var latinSpecialMap = map[rune]rune{
+	'Đ': 'D',
+	'đ': 'd',
+	'Ħ': 'H',
+	'ħ': 'h',
+	'ĸ': 'K',
+	'Ŀ': 'L',
+	'Ł': 'L',
+	'ŉ': 'n',
+	'Ŋ': 'N',
+	'ŋ': 'n',
+	'Ŧ': 'T',
+	'ŧ': 't',
+}
+
 // mapLatinSpecial attempts to map non-accented latin extended runes to ASCII
 func mapLatinSpecial(r rune) rune {
-	switch r {
-	case 'Đ':
-		return 'D'
-	case 'đ':
-		return 'd'
-	case 'Ħ':
-		return 'H'
-	case 'ħ':
-		return 'h'
-	case 'ĸ':
-		return 'K'
-	case 'Ŀ':
-		return 'L'
-	case 'Ł':
-		return 'L'
-	case 'ŉ':
-		return 'n'
-	case 'Ŋ':
-		return 'N'
-	case 'ŋ':
-		return 'n'
-	case 'Ŧ':
-		return 'T'
-	case 'ŧ':
-		return 't'
+	if v, ok := latinSpecialMap[r]; ok {
+		return v
 	}
 	if r > 0x7e {
 		return '_'


Test patch:
diff --git a/boundary_test.go b/boundary_test.go
index dafa9eb..2146836 100644
--- a/boundary_test.go
+++ b/boundary_test.go
@@ -7,6 +7,8 @@ import (
 	"io/ioutil"
 	"strings"
 	"testing"
+
+	"github.com/pkg/errors"
 )
 
 func TestBoundaryReader(t *testing.T) {
@@ -420,3 +422,38 @@ func TestBoundaryReaderBufferBoundaryCross(t *testing.T) {
 		t.Errorf("ReadAll() got: %q, want: %q", got, want)
 	}
 }
+
+func TestBoundaryReaderReadErrors(t *testing.T) {
+	// Destination byte slice is shorter than buffer length
+	dest := make([]byte, 1)
+	br := &boundaryReader{
+		buffer: bytes.NewBuffer([]byte{'1', '2', '3'}),
+	}
+	n, err := br.Read(dest)
+	if n != 1 {
+		t.Fatal("Read() did not read bytes equal to len(dest), failed")
+	}
+	if err != nil {
+		t.Fatal("Read() should not have returned an error, failed")
+	}
+
+	// Using bufio.Reader with a 0 length buffer will cause
+	// Peek method to return a non io.EOF error.
+	dest = make([]byte, 10)
+	br.r = &bufio.Reader{}
+	n, err = br.Read(dest)
+	if n != 0 {
+		t.Fatal("Read() should not have read any bytes, failed")
+	}
+	if errors.Cause(err) != bufio.ErrBufferFull {
+		t.Fatal("Read() should have returned bufio.ErrBufferFull error, failed")
+	}
+	// Next method to return a non io.EOF error.
+	next, err := br.Next()
+	if next {
+		t.Fatal("Next() should have returned false, failed")
+	}
+	if errors.Cause(err) != bufio.ErrBufferFull {
+		t.Fatal("Read() should have returned bufio.ErrBufferFull error, failed")
+	}
+}
diff --git a/builder_test.go b/builder_test.go
index 580df16..719c8a5 100644
--- a/builder_test.go
+++ b/builder_test.go
@@ -3,9 +3,11 @@ package enmime_test
 import (
 	"bytes"
 	"net/mail"
+	"net/smtp"
 	"path/filepath"
 	"reflect"
 	"strconv"
+	"strings"
 	"testing"
 	"time"
 
@@ -923,3 +925,29 @@ func TestBuilderQPHeaders(t *testing.T) {
 
 	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "build-qp-addr-headers.golden")
 }
+
+func TestSend(t *testing.T) {
+	// Satisfy all block of the Send method and use
+	// an intentionally malformed From Address to
+	// elicit an expected error from smtp.SendMail,
+	// which can be type-checked and verified.
+	text := "test text body"
+	html := "test html body"
+	a := enmime.Builder().
+		Text([]byte(text)).
+		HTML([]byte(html)).
+		From("name", "foo\rbar").
+		Subject("foo").
+		ToAddrs(addrSlice).
+		CCAddrs(addrSlice).
+		BCCAddrs(addrSlice)
+	// Dummy SMTP Authentication
+	auth := smtp.PlainAuth("", "user@example.com", "password", "mail.example.com")
+	err := a.Send("0.0.0.0", auth)
+	if err == nil {
+		t.Fatal("Send() did not return expected error, failed")
+	}
+	if !strings.Contains(err.Error(), "smtp: A line must not contain CR or LF") {
+		t.Fatalf("Send() did not return expected error, failed: %s", err.Error())
+	}
+}
diff --git a/cmd/mime-dump/mime-dump_test.go b/cmd/mime-dump/mime-dump_test.go
new file mode 100644
index 0000000..596cbc0
--- /dev/null
+++ b/cmd/mime-dump/mime-dump_test.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+	"bytes"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestNewDefaultDumper(t *testing.T) {
+	if newDefaultDumper() == nil {
+		t.Fatal("Dumper instance should not be nil")
+	}
+}
+
+func TestNotEnoughArgs(t *testing.T) {
+	b := &bytes.Buffer{}
+	d := &dumper{
+		errOut: b,
+	}
+	exitCode := d.dump(nil)
+	if exitCode != 1 {
+		t.Fatal("Should have returned an exit code of 1, failed")
+	}
+	if b.String() != "Missing filename argument\n" {
+		t.Fatal("Should be missing filename argument, failed")
+	}
+}
+
+func TestFailedToOpenFile(t *testing.T) {
+	b := &bytes.Buffer{}
+	d := &dumper{
+		errOut: b,
+	}
+	exitCode := d.dump([]string{"", ""})
+	if exitCode != 1 {
+		t.Fatal("Should have returned an exit code of 1, failed")
+	}
+	if !strings.HasPrefix(b.String(), "Failed to open file") {
+		t.Fatal("Should have failed to open file, failed")
+	}
+}
+
+func TestFailedToParseFile(t *testing.T) {
+	b := &bytes.Buffer{}
+	d := &dumper{
+		errOut: b,
+	}
+	exitCode := d.dump([]string{"", filepath.Join("..", "..", "testdata", "mail", "erroneous.raw")})
+	if exitCode != 1 {
+		t.Fatal("Should have returned an exit code of 1, failed")
+	}
+	if !strings.HasPrefix(b.String(), "During enmime.ReadEnvelope") {
+		t.Fatal("Should have failed to parse file, failed")
+	}
+}
+
+func TestSuccess(t *testing.T) {
+	b := &bytes.Buffer{}
+	s := &bytes.Buffer{}
+	d := &dumper{
+		errOut: b,
+		stdOut: s,
+	}
+	exitCode := d.dump([]string{"", filepath.Join("..", "..", "testdata", "mail", "attachment.raw")})
+	if exitCode != 0 {
+		t.Fatal("Should have returned an exit code of 0, failed")
+	}
+	if b.Len() > 0 {
+		t.Fatal("Should not have produced any errors, failed")
+	}
+	if !(s.Len() > 0) {
+		t.Fatal("Should have printed markdown document, failed")
+	}
+}
diff --git a/cmd/mime-extractor/mime-extractor_test.go b/cmd/mime-extractor/mime-extractor_test.go
new file mode 100644
index 0000000..7a9f80e
--- /dev/null
+++ b/cmd/mime-extractor/mime-extractor_test.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestNewDefaultExtractor(t *testing.T) {
+	if newDefaultExtractor() == nil {
+		t.Fatal("Extractor instance should not be nil")
+	}
+}
+
+func TestExtractEmptyFilename(t *testing.T) {
+	b := &bytes.Buffer{}
+	testExtractor := &extractor{
+		errOut: b,
+		stdOut: ioutil.Discard,
+	}
+	exitCode := testExtractor.extract("", "")
+	if exitCode != 1 {
+		t.Fatal("Exit code should be 1, failed")
+	}
+	if !strings.Contains(b.String(), "Missing filename argument") {
+		t.Fatal("Should not succeed with an empty filename, failed")
+	}
+}
+
+func TestExtractEmptyOutputDirectory(t *testing.T) {
+	b := &bytes.Buffer{}
+	workingDirectoryFn := func() (string, error) {
+		return "", nil
+	}
+	testExtractor := &extractor{
+		errOut: b,
+		stdOut: ioutil.Discard,
+		wd:     workingDirectoryFn,
+	}
+	exitCode := testExtractor.extract("some.file", "")
+	if exitCode != 2 {
+		t.Fatal("Exit code should be 2, failed")
+	}
+	if !strings.Contains(b.String(), "Mkdir  failed.") {
+		t.Fatal("Should not succeed with an empty output directory, failed")
+	}
+}
+
+func TestExtractFailedToOpenFile(t *testing.T) {
+	b := &bytes.Buffer{}
+	testExtractor := &extractor{
+		errOut: b,
+		stdOut: ioutil.Discard,
+		wd:     os.Getwd,
+	}
+	exitCode := testExtractor.extract("some.file", "")
+	if exitCode != 1 {
+		t.Fatal("Exit code should be 1, failed")
+	}
+	if !strings.Contains(b.String(), "Failed to open file:") {
+		t.Fatal("File should not exist, failed")
+	}
+}
+
+func TestExtractFailedToParse(t *testing.T) {
+	s := &bytes.Buffer{}
+	testExtractor := &extractor{
+		errOut: s,
+		stdOut: ioutil.Discard,
+		wd:     os.Getwd,
+	}
+	exitCode := testExtractor.extract(filepath.Join("..", "..", "testdata", "mail", "erroneous.raw"), "")
+	if exitCode != 1 {
+		t.Fatal("Exit code should be 1, failed")
+	}
+	if !strings.Contains(s.String(), "During enmime.ReadEnvelope") {
+		t.Fatal("Should have failed to write the attachment, failed")
+	}
+}
+
+func TestExtractAttachmentWriteFail(t *testing.T) {
+	s := &bytes.Buffer{}
+	fw := func(filename string, data []byte, perm os.FileMode) error {
+		return fmt.Errorf("AttachmentWriteFail")
+	}
+	testExtractor := &extractor{
+		errOut:    ioutil.Discard,
+		stdOut:    s,
+		wd:        os.Getwd,
+		fileWrite: fw,
+	}
+	exitCode := testExtractor.extract(filepath.Join("..", "..", "testdata", "mail", "attachment.raw"), "")
+	if exitCode != 0 {
+		t.Fatal("Exit code should be 0, failed")
+	}
+	if !strings.HasSuffix(s.String(), "AttachmentWriteFail\n") {
+		t.Fatal("Should have failed to write the attachment, failed")
+	}
+}
+
+func TestExtractSuccess(t *testing.T) {
+	b := &bytes.Buffer{}
+	attachmentCount := 0
+	fw := func(filename string, data []byte, perm os.FileMode) error {
+		attachmentCount++
+		return nil
+	}
+	testExtractor := &extractor{
+		errOut:    b,
+		stdOut:    ioutil.Discard,
+		wd:        os.Getwd,
+		fileWrite: fw,
+	}
+	exitCode := testExtractor.extract(filepath.Join("..", "..", "testdata", "mail", "attachment.raw"), "")
+	if exitCode != 0 {
+		t.Fatal("Exit code should be 0, failed")
+	}
+	if attachmentCount < 1 {
+		t.Fatal("Should be one attachment, failed")
+	}
+	if !strings.Contains(b.String(), "Done!") {
+		t.Fatal("Should have succeeded, failed")
+	}
+}
diff --git a/encode_test.go b/encode_test.go
index fe039b7..806c167 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -31,6 +31,18 @@ func TestEncodePartHeaderOnly(t *testing.T) {
 	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-header-only.golden")
 }
 
+func TestEncodePartHeaderOnlyDefaultTransferEncoding(t *testing.T) {
+	p := enmime.NewPart("text/plain")
+	p.Header.Add("X-Empty-Header", "")
+
+	b := &bytes.Buffer{}
+	err := p.Encode(b)
+	if err != nil {
+		t.Fatal(err)
+	}
+	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-header-only-default-encoding.golden")
+}
+
 func TestEncodePartDefaultHeaders(t *testing.T) {
 	p := enmime.NewPart("application/zip")
 	p.Boundary = "enmime-abcdefg0123456789"
@@ -67,6 +79,25 @@ func TestEncodePartQuotedHeaders(t *testing.T) {
 	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-quoted-headers.golden")
 }
 
+func TestEncodePartQuotedPrintableHeaders(t *testing.T) {
+	p := enmime.NewPart("application/zip")
+	p.Boundary = "enmime-abcdefg0123456789"
+	p.Charset = "binary"
+	p.ContentID = "mycontentid"
+	p.Disposition = "attachment"
+	p.FileName = `árvíztűrő "x" tükörfúrógép.zip`
+	p.FileModDate, _ = time.Parse(time.RFC822, "01 Feb 03 04:05 GMT")
+	p.Header.Add("X-QP-Header", "Just enough to need qp ☆")
+	p.Content = []byte("ZIPZIPZIP")
+
+	b := &bytes.Buffer{}
+	err := p.Encode(b)
+	if err != nil {
+		t.Fatal(err)
+	}
+	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-quoted-printable-headers.golden")
+}
+
 func TestEncodePartBinaryHeader(t *testing.T) {
 	p := enmime.NewPart("text/plain")
 	p.Header.Set("Subject", "¡Hola, señor!")
@@ -99,6 +130,18 @@ func TestEncodePartContentOnly(t *testing.T) {
 	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-content-only.golden")
 }
 
+func TestEncodePartContentOnlyQP(t *testing.T) {
+	p := &enmime.Part{}
+	p.Content = []byte("☆ No header, only content.")
+
+	b := &bytes.Buffer{}
+	err := p.Encode(b)
+	if err != nil {
+		t.Fatal(err)
+	}
+	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-content-only-qp.golden")
+}
+
 func TestEncodePartPlain(t *testing.T) {
 	p := enmime.NewPart("text/plain")
 	p.Content = []byte("This is a test of a plain text part.\r\n\r\nAnother line.\r\n")
diff --git a/envelope_test.go b/envelope_test.go
index b0aa841..b25f70d 100644
--- a/envelope_test.go
+++ b/envelope_test.go
@@ -1106,3 +1106,42 @@ func TestCloneEnvelope(t *testing.T) {
 	clone := e.Clone()
 	test.CompareEnvelope(t, clone, e)
 }
+
+func TestNilHeaderValues(t *testing.T) {
+	e := &enmime.Envelope{}
+	values := e.GetHeaderValues("")
+	if len(values) > 0 {
+		t.Fatal("No header values should be available, failed")
+	}
+}
+
+func TestSetHeaderEmptyName(t *testing.T) {
+	e := &enmime.Envelope{}
+	err := e.SetHeader("", nil)
+	if err == nil || err.Error() != "Provide non-empty header name" {
+		t.Fatal("Cannot set a header without a name, failed")
+	}
+}
+
+func TestAddHeaderEmptyName(t *testing.T) {
+	e := &enmime.Envelope{}
+	err := e.AddHeader("", "")
+	if err == nil || err.Error() != "Provide non-empty header name" {
+		t.Fatal("Cannot add a header without a name, failed")
+	}
+}
+
+func TestDeleteHeaderEmptyName(t *testing.T) {
+	e := &enmime.Envelope{}
+	err := e.DeleteHeader("")
+	if err == nil || err.Error() != "Provide non-empty header name" {
+		t.Fatal("Cannot add a header without a name, failed")
+	}
+}
+
+func TestNilEnvelopeClone(t *testing.T) {
+	var e *enmime.Envelope
+	if e.Clone() != nil {
+		t.Fatal("Clone of nil envelope is not nil, failed")
+	}
+}
diff --git a/error_test.go b/error_test.go
index e1dd23e..85c9638 100644
--- a/error_test.go
+++ b/error_test.go
@@ -25,8 +25,9 @@ func TestErrorStringConversion(t *testing.T) {
 		Severe: true,
 	}
 
+	// Using deprecated String() method here for test coverage
 	want = "[E] ErrorName: Error Details"
-	got = e.Error()
+	got = e.String()
 	if got != want {
 		t.Error("got:", got, "want:", want)
 	}
diff --git a/inspect_test.go b/inspect_test.go
index 862ecfe..826e05f 100644
--- a/inspect_test.go
+++ b/inspect_test.go
@@ -5,6 +5,7 @@ import (
 	"io"
 	"io/ioutil"
 	"net/mail"
+	"net/textproto"
 	"strings"
 	"testing"
 
@@ -28,6 +29,36 @@ func TestDecodeHeaders(t *testing.T) {
 		}
 	})
 
+	t.Run("no break between headers and content", func(t *testing.T) {
+		r := test.OpenTestData("mail", "qp-utf8-header-no-break.raw")
+		b, err := ioutil.ReadAll(r)
+		if err != nil {
+			t.Errorf("%+v", err)
+		}
+		h, err := enmime.DecodeHeaders(b)
+		if err != nil {
+			t.Errorf("%+v", err)
+		}
+		if !strings.Contains(h.Get("To"), "Mirosław Marczak") {
+			t.Errorf("Error decoding RFC2047 header value")
+		}
+	})
+
+	t.Run("textproto header read error", func(t *testing.T) {
+		r := test.OpenTestData("low-quality", "bad-header-start.raw")
+		b, err := ioutil.ReadAll(r)
+		if err != nil {
+			t.Errorf("%+v", err)
+		}
+		_, err = enmime.DecodeHeaders(b)
+		switch err.(type) {
+		case textproto.ProtocolError:
+			// carry on
+		default:
+			t.Fatalf("Did return expected error: %T:%+v", err, err)
+		}
+	})
+
 	t.Run("rfc2047 recursive sample", func(t *testing.T) {
 		r := test.OpenTestData("mail", "qp-utf8-header-recursed.raw")
 		b, err := ioutil.ReadAll(r)
diff --git a/part_test.go b/part_test.go
index bc46a7a..d7e128b 100644
--- a/part_test.go
+++ b/part_test.go
@@ -36,6 +36,30 @@ func TestPlainTextPart(t *testing.T) {
 	test.ContentContainsString(t, p.Content, want)
 }
 
+func TestAddChildInfiniteLoops(t *testing.T) {
+	// Part adds itself
+	parentPart := &enmime.Part{
+		ContentType: "text/plain",
+		Charset:     "us-ascii",
+		PartID:      "0",
+	}
+	parentPart.AddChild(parentPart)
+
+	// Part adds its own FirstChild
+	childPart := &enmime.Part{
+		ContentType: "text/plain",
+		Charset:     "us-ascii",
+		PartID:      "1",
+	}
+	parentPart.FirstChild = childPart
+	parentPart.AddChild(childPart)
+
+	parentPart.FirstChild = nil
+	// Part adds a child that is its own NextSibling
+	childPart.NextSibling = childPart
+	parentPart.AddChild(childPart)
+}
+
 func TestQuotedPrintablePart(t *testing.T) {
 	var want, got string
 	var wantp *enmime.Part
diff --git a/testdata/encode/part-content-only-qp.golden b/testdata/encode/part-content-only-qp.golden
new file mode 100644
index 0000000..2eab99e
--- /dev/null
+++ b/testdata/encode/part-content-only-qp.golden
@@ -0,0 +1,3 @@
+Content-Transfer-Encoding: quoted-printable
+
+=E2=98=86 No header, only content.
\ No newline at end of file
diff --git a/testdata/encode/part-header-only-default-encoding.golden b/testdata/encode/part-header-only-default-encoding.golden
new file mode 100644
index 0000000..6918ddc
--- /dev/null
+++ b/testdata/encode/part-header-only-default-encoding.golden
@@ -0,0 +1,2 @@
+Content-Type: text/plain
+X-Empty-Header: 
diff --git a/testdata/encode/part-quoted-printable-headers.golden b/testdata/encode/part-quoted-printable-headers.golden
new file mode 100644
index 0000000..f468c7d
--- /dev/null
+++ b/testdata/encode/part-quoted-printable-headers.golden
@@ -0,0 +1,9 @@
+Content-Disposition: attachment; filename="arvizturo \"x\"
+ tukorfurogep.zip"; modification-date="01 Feb 03 04:05 GMT"
+Content-Id: <mycontentid>
+Content-Transfer-Encoding: base64
+Content-Type: application/zip; boundary=enmime-abcdefg0123456789;
+ charset=binary; name="arvizturo \"x\" tukorfurogep.zip"
+X-Qp-Header: =?utf-8?q?Just_enough_to_need_qp_=E2=98=86?=
+
+WklQWklQWklQ
diff --git a/testdata/low-quality/bad-header-start.raw b/testdata/low-quality/bad-header-start.raw
new file mode 100644
index 0000000..3e22bde
--- /dev/null
+++ b/testdata/low-quality/bad-header-start.raw
@@ -0,0 +1,16 @@
+ From: James Hillyerd <james@makita.skynet>
+Subject: plain text
+Date: Thu, 18 Oct 2012 22:48:39 -0700
+Message-Id: <07B7061D-2676-487E-942E-C341CE4D13DC@makita.skynet>
+To: greg@inbucket
+Content-Type: multipart/alternative; boundary="Enmime-Test-100"
+
+--Enmime-Test-100
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain; charset=us-ascii
+
+A text section
+--Enmime-Test-100
+
+An HTML section
+--Enmime-Test-100--
diff --git a/testdata/mail/erroneous.raw b/testdata/mail/erroneous.raw
new file mode 100644
index 0000000..398270b
--- /dev/null
+++ b/testdata/mail/erroneous.raw
@@ -0,0 +1,1 @@
+// Not an RFC-822 Document
\ No newline at end of file
diff --git a/testdata/mail/qp-utf8-header-no-break.raw b/testdata/mail/qp-utf8-header-no-break.raw
new file mode 100644
index 0000000..ad3e94b
--- /dev/null
+++ b/testdata/mail/qp-utf8-header-no-break.raw
@@ -0,0 +1,79 @@
+Message-ID: <5081A889.3020108@jamehi03lx.noa.com>
+Date: Fri, 19 Oct 2012 12:22:49 -0700
+From: James Hillyerd <jamehi03@jamehi03lx.noa.com>, =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>
+Sender: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>
+User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:16.0) Gecko/20121010 Thunderbird/16.0.1
+MIME-Version: 1.0
+To: =?UTF-8?Q?Miros=C5=82aw_Marczak?= <marczak@inbucket.com>
+Subject: =?utf-8?q?MIME_UTF8_Test_=c2=a2?= More Text
+Content-Type: multipart/alternative;
+ boundary="------------020203040006070307010003"
+This is a multi-part message in MIME format.
+--------------020203040006070307010003
+Content-Type: text/plain; charset=ISO-8859-1
+Content-Transfer-Encoding: quoted-printable
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam =
+venenatis ante fermentum justo varius hendrerit. Sed adipiscing =
+adipiscing nisi at placerat. Sed luctus neque pellentesque sem laoreet =
+dapibus. Class aptent taciti sociosqu ad litora torquent per conubia =
+nostra, per inceptos himenaeos. In eu scelerisque nibh. Fusce faucibus, =
+nisl vel tincidunt sodales, quam est condimentum lorem, vitae semper =
+lacus nisl vel lacus. Vestibulum vitae iaculis urna. Donec pellentesque =
+pellentesque ipsum sit amet suscipit. Ut aliquam vestibulum justo sit =
+amet congue. Mauris nisl lacus, varius a ultrices id, suscipit mattis =
+libero.
+
+Donec iaculis dapibus purus in accumsan. Cras faucibus, orci dictum =
+molestie congue, neque magna adipiscing lacus, ut laoreet sapien ante et =
+mi. Suspendisse potenti. Nullam sed dui magna. Nullam vel purus augue, =
+imperdiet vehicula purus. Lorem ipsum dolor sit amet, consectetur =
+adipiscing elit. In aliquet, ante at tincidunt ultricies, arcu dolor =
+rutrum odio, quis tempus lacus leo a quam. Fusce hendrerit, urna sed =
+elementum pulvinar, dolor sem imperdiet arcu, ut ornare erat metus sit =
+amet diam. Duis sagittis libero ut metus vulputate dictum. Etiam eget =
+augue dolor, in lacinia felis. Nulla facilisi. Proin sollicitudin =
+laoreet vehicula. Praesent non nibh odio.
+
+
+--------------020203040006070307010003
+Content-Type: text/html; charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+
+<html>
+  <head>
+
+    <meta http-equiv="content-type" content="text/html; charset=ISO-8859-1">
+  </head>
+  <body bgcolor="#FFFFFF" text="#000000">
+    <div id="lipsum">
+      <p>
+        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam
+        venenatis ante fermentum justo varius hendrerit. Sed adipiscing
+        adipiscing nisi at placerat. Sed luctus neque pellentesque sem
+        laoreet dapibus. Class aptent taciti sociosqu ad litora torquent
+        per conubia nostra, per inceptos himenaeos. In eu scelerisque
+        nibh. Fusce faucibus, nisl vel tincidunt sodales, quam est
+        condimentum lorem, vitae semper lacus nisl vel lacus. Vestibulum
+        vitae iaculis urna. Donec pellentesque pellentesque ipsum sit
+        amet suscipit. Ut aliquam vestibulum justo sit amet congue.
+        Mauris nisl lacus, varius a ultrices id, suscipit mattis libero.
+      </p>
+      <p>
+        Donec iaculis dapibus purus in accumsan. Cras faucibus, orci
+        dictum molestie congue, neque magna adipiscing lacus, ut laoreet
+        sapien ante et mi. Suspendisse potenti. Nullam sed dui magna.
+        Nullam vel purus augue, imperdiet vehicula purus. Lorem ipsum
+        dolor sit amet, consectetur adipiscing elit. In aliquet, ante at
+        tincidunt ultricies, arcu dolor rutrum odio, quis tempus lacus
+        leo a quam. Fusce hendrerit, urna sed elementum pulvinar, dolor
+        sem imperdiet arcu, ut ornare erat metus sit amet diam. Duis
+        sagittis libero ut metus vulputate dictum. Etiam eget augue
+        dolor, in lacinia felis. Nulla facilisi. Proin sollicitudin
+        laoreet vehicula. Praesent non nibh odio.
+      </p>
+    </div>
+  </body>
+</html>
+
+--------------020203040006070307010003--