Repository: jhillyerd/enmime
Pull request #103 (2018-11-29 23:11:47)

Handle non-delimited address lists

requaos: I tried to follow the TravisCI output, made no sense, couldn't find the test it mentioned and the line number in `encode_test.go:179` is just `if err != nil {`... fishy
```✔ ~/go/src/github.com/jhillyerd/enmime [requaos/multipleFrom|⚑ 1] 
18:08 $ make
go clean github.com/jhillyerd/enmime github.com/jhillyerd/enmime/cmd github.com/jhillyerd/enmime/cmd/mime-dump github.com/jhillyerd/enmime/cmd/mime-extractor github.com/jhillyerd/enmime/internal/coding github.com/jhillyerd/enmime/internal/stringutil github.com/jhillyerd/enmime/internal/test
go test -race ./...
ok      github.com/jhillyerd/enmime     2.162s
ok      github.com/jhillyerd/enmime/cmd 1.583s
?       github.com/jhillyerd/enmime/cmd/mime-dump       [no test files]
?       github.com/jhillyerd/enmime/cmd/mime-extractor  [no test files]
ok      github.com/jhillyerd/enmime/internal/coding     1.566s
ok      github.com/jhillyerd/enmime/internal/stringutil 1.553s
ok      github.com/jhillyerd/enmime/internal/test       1.566s
go build
```
jhillyerd: Not your fault, I moved #102 from master to develop, so its golden test file does not reflect the newline changes from #99.  I will fix it tonight and re-run your test.

Patch:
diff --git a/README.md b/README.md
index 395dd32..6ab6d0b 100644
--- a/README.md
+++ b/README.md
@@ -15,27 +15,19 @@ API documentation and examples can be found here:
 http://godoc.org/github.com/jhillyerd/enmime
 
 
-## API Changes
-
-Part readers: `Part.Read()` and `Part.Utf8Reader` have been removed. Please use
-`Part.Content` instead.
-
-A brief guide to migrating from the old 2016 `go.enmime` API is available here:
-https://github.com/jhillyerd/enmime/wiki/Enmime-Migration-Guide
-
-
 ## Development Status
 
-enmime is beta quality: it works but has not been tested with a wide variety of
-source data.  It's possible the API will evolve slightly before an official
-release.
+**Please base PRs off the `develop` branch**, enmime uses `master` for stable
+releases.  See [CONTRIBUTING.md] for more information.
 
-Please see [CONTRIBUTING.md] if you'd like to contribute code to the project.
+enmime is approaching production quality: it works but has not been tested with
+a wide variety of source data.  It's possible the API will evolve slightly
+before an official release.
 
 
 ## About
 
-enmime is written in [Google Go][Golang].
+enmime is written in [Go][Golang].
 
 enmime is open source software released under the MIT License.  The latest
 version can be found at https://github.com/jhillyerd/enmime
diff --git a/envelope.go b/envelope.go
index 6813c4d..1721c6b 100644
--- a/envelope.go
+++ b/envelope.go
@@ -114,11 +114,20 @@ func (e *Envelope) AddressList(key string) ([]*mail.Address, error) {
 	if str == "" {
 		return nil, mail.ErrHeaderNotPresent
 	}
+
 	// These statements are handy for debugging ParseAddressList errors
 	// fmt.Println("in:  ", m.header.Get(key))
 	// fmt.Println("out: ", str)
 	ret, err := mail.ParseAddressList(str)
-	if err != nil {
+	switch {
+	case err == nil:
+		// carry on
+	case err.Error() == "mail: expected comma":
+		ret, err = mail.ParseAddressList(ensureCommaDelimitedAddresses(str))
+		if err != nil {
+			return nil, err
+		}
+	default:
 		return nil, err
 	}
 	return ret, nil
@@ -336,3 +345,62 @@ func parseMultiPartBody(root *Part, e *Envelope) error {
 func matchHTMLBodyPart(p *Part) bool {
 	return p.ContentType == ctTextHTML && p.Disposition != cdAttachment
 }
+
+// Used by AddressList to ensure that address lists are properly delimited
+func ensureCommaDelimitedAddresses(s string) string {
+	// This normalizes the whitespace, but may interfere with CFWS (comments with folding whitespace)
+	// RFC-5322 3.4.0:
+	//      because some legacy implementations interpret the comment,
+	//      comments generally SHOULD NOT be used in address fields
+	//      to avoid confusing such implementations.
+	s = strings.Join(strings.Fields(s), " ")
+
+	inQuotes := false
+	inDomain := false
+	escapeSequence := false
+	sb := strings.Builder{}
+	for _, r := range s {
+		if escapeSequence {
+			escapeSequence = false
+			sb.WriteRune(r)
+			continue
+		}
+		if r == '"' {
+			inQuotes = !inQuotes
+			sb.WriteRune(r)
+			continue
+		}
+		if inQuotes {
+			if r == '\\' {
+				escapeSequence = true
+				sb.WriteRune(r)
+				continue
+			}
+		} else {
+			if r == '@' {
+				inDomain = true
+				sb.WriteRune(r)
+				continue
+			}
+			if inDomain {
+				if r == ';' {
+					sb.WriteRune(r)
+					break
+				}
+				if r == ',' {
+					inDomain = false
+					sb.WriteRune(r)
+					continue
+				}
+				if r == ' ' {
+					inDomain = false
+					sb.WriteRune(',')
+					sb.WriteRune(r)
+					continue
+				}
+			}
+		}
+		sb.WriteRune(r)
+	}
+	return sb.String()
+}


Test patch:
diff --git a/envelope_test.go b/envelope_test.go
index 6e697f0..4df32ed 100644
--- a/envelope_test.go
+++ b/envelope_test.go
@@ -869,6 +869,24 @@ func TestUnquotedSpecialCharParamsInMime(t *testing.T) {
 	}
 }
 
+func TestBadAddressHeaderInMime(t *testing.T) {
+	msg := test.OpenTestData("mail", "malformed-multiple-address-header-values.raw")
+	e, err := enmime.ReadEnvelope(msg)
+
+	if err != nil {
+		t.Fatal("Failed to parse MIME:", err)
+	}
+
+	froms, err := e.AddressList("From")
+	if err != nil {
+		t.Log(err)
+	}
+
+	if len(froms) < 1 {
+		t.Fatal("From header should have at least one entry")
+	}
+}
+
 func TestBadContentTypeInMime(t *testing.T) {
 	msg := test.OpenTestData("mail", "mime-bad-content-type.raw")
 	e, err := enmime.ReadEnvelope(msg)
diff --git a/header_test.go b/header_test.go
index c893762..4a3c3c3 100644
--- a/header_test.go
+++ b/header_test.go
@@ -475,3 +475,65 @@ func TestReadHeader(t *testing.T) {
 		}
 	}
 }
+
+func TestCommaDelimitedAddressLists(t *testing.T) {
+	testData := []struct {
+		have string
+		want string
+	}{
+		{
+			have: `"Joe @ Company" <joe@company.com> <other@company.com>`,
+			want: `"Joe @ Company" <joe@company.com>, <other@company.com>`,
+		},
+		{
+			have: `Joe Company <joe@company.com> <other@company.com>`,
+			want: `Joe Company <joe@company.com>, <other@company.com>`,
+		},
+		{
+			have: `Joe Company:Joey <joe@company.com> John <other@company.com>;`,
+			want: `Joe Company:Joey <joe@company.com>, John <other@company.com>;`,
+		},
+		{
+			have: `Joe Company:Joey <joe@company.com> John <other@company.com>; Jimmy John <jimmy.john@company.com>`,
+			want: `Joe Company:Joey <joe@company.com>, John <other@company.com>;`,
+		},
+		{
+			have: `Joe Company <joe@company.com> John Company <other@company.com>`,
+			want: `Joe Company <joe@company.com>, John Company <other@company.com>`,
+		},
+		{
+			have: `Joe Company <joe@company.com>,John Company <other@company.com>`,
+			want: `Joe Company <joe@company.com>,John Company <other@company.com>`,
+		},
+		{
+			have: `joe@company.com other@company.com`,
+			want: `joe@company.com, other@company.com`,
+		},
+		{
+			have: `Jimmy John <jimmy.john@company.com> joe@company.com other@company.com`,
+			want: `Jimmy John <jimmy.john@company.com>, joe@company.com, other@company.com`,
+		},
+		{
+			have: `Jimmy John <jimmy.john@company.com> joe@company.com John Company <other@company.com>`,
+			want: `Jimmy John <jimmy.john@company.com>, joe@company.com, John Company <other@company.com>`,
+		},
+		{
+			have: `<boss@nil.test> "Giant; \"Big\" Box" <sysservices@example.net>`,
+			want: `<boss@nil.test>, "Giant; \"Big\" Box" <sysservices@example.net>`,
+		},
+		{
+			have: `A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;`,
+			want: `A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;`,
+		},
+		{
+			have: `A Group:Ed Jones <c@a.test> joe@where.test John <jdoe@one.test>;`,
+			want: `A Group:Ed Jones <c@a.test>, joe@where.test, John <jdoe@one.test>;`,
+		},
+	}
+	for i := range testData {
+		v := ensureCommaDelimitedAddresses(testData[i].have)
+		if testData[i].want != v {
+			t.Fatalf("Expected %s, but got %s", testData[i].want, v)
+		}
+	}
+}
diff --git a/testdata/encode/part-quotable-content.golden b/testdata/encode/part-quotable-content.golden
index 4eb0324..358abf7 100644
--- a/testdata/encode/part-quotable-content.golden
+++ b/testdata/encode/part-quotable-content.golden
@@ -1,3 +1,3 @@
 Content-Type: text/plain; charset=utf-8
 
-Hello=
+Hello=
\ No newline at end of file
diff --git a/testdata/mail/malformed-multiple-address-header-values.raw b/testdata/mail/malformed-multiple-address-header-values.raw
new file mode 100644
index 0000000..def90e6
--- /dev/null
+++ b/testdata/mail/malformed-multiple-address-header-values.raw
@@ -0,0 +1,39 @@
+Subject: Test
+To: alice@intern
+From: Bob Dunite <bob@extern> Bobby Dunite <bobby@extern>
+Content-Type: multipart/mixed;
+	boundary="0__=4EBB0828DFD318FF8f9e8a93df938690918c4EBB0828DFD318FF"
+Content-Disposition: inline
+MIME-Version: 1.0
+
+--0__=4EBB0828DFD318FF8f9e8a93df938690918c4EBB0828DFD318FF
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Simple text.
+
+--0__=4EBB0828DFD318FF8f9e8a93df938690918c4EBB0828DFD318FF
+Content-Type: application/pdf; name="test.pdf"; name="test.pdf"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="test.pdf"
+
+JVBERi0xLjINCg0KNCAwIG9iag0KPDwNCi9FIDYxOTUNCi9IIFsgMTIxMSAxNDUgXQ0KL0wgNjUy
+MA0KL0xpbmVhcml6ZWQgMQ0KL04gMQ0KL08gNw0KL1QgNjM5MA0KPj4gICAgICAgICAgICAgICAg
+ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg
+DQplbmRvYmoNCg0KeHJlZg0KNCA4DQowMDAwMDAwMDEyIDAwMDAwIG4NCjAwMDAwMDEwODggMDAw
+MDAgbg0KMDAwMDAwMTIxMSAwMDAwMCBuDQowMDAwMDAxMzU2IDAwMDAwIG4NCjAwMDAwMDE2MDcg
+MDAwMDAgbg0KMDAwMDAwMTcxNiAwMDAwMCBuDQowMDAwMDAxODI0IDAwMDAwIG4NCjAwMDAwMDQz
+MTkgMDAwMDAgbg0KdHJhaWxlcg0KPDwNCi9BQkNwZGYgODEwNw0KL0lEIFsgPDNENzc5REU2NjU0
+RDM4ODczMTYzODBBNTY3REY0MDhFPg0KPDc5OEQ0RjVGNkUyNzIzQjA1MTFCQzU0QzYwMTczRDI3
+PiBdDQovSW5mbyAzIDAgUg0KL1ByZXYgNjM4MA0KL1Jvb3QgNSAwIFINCi9TaXplIDEyDQovU291
+cmNlIChXZUpYRnh
+
+--0__=4EBB0828DFD318FF8f9e8a93df938690918c4EBB0828DFD318FF
+Content-Type: text/plain; name="test.txt"; name="test.txt"
+Content-Disposition: inline
+Content-Transfer-Encoding: 7bit
+Content-Disposition: attachment; filename="test.txt"
+
+Text attachment.
+
+--0__=4EBB0828DFD318FF8f9e8a93df938690918c4EBB0828DFD318FF--