Repository: jhillyerd/enmime (go)

Issue:
Failed to parse address with colon
What I did: I have valid header `From: =?utf-8?Q?re=3AStore=20?=
 =?utf-8?Q?Apple=20?=
 =?utf-8?Q?Premium=20?=
 =?utf-8?Q?Reseller?= <noreply@mail.restore.ru>`

What I expected: Parsed successfully

What I got:  error = `mail: expected comma`

Release or branch I am using: master

Comment: looks like we need to add quotes as it [standard lib](https://play.golang.org/p/12Njee_-HSB) do

Example message:
```
Received: id 2D333CD836-1631660949
Message-Id: <20210915020909.0.20210915020909_pe_@CmKAw7AV7BGnRfumzamcPQ3185481631660948.escheclub.pegate>
Date: Wed, 15 Sep 2021 02:09:09 +0300
From: =?utf-8?Q?re=3AStore=20?=
 =?utf-8?Q?Apple=20?=
 =?utf-8?Q?Premium=20?=
 =?utf-8?Q?Reseller?= <noreply@mail.restore.ru>
To:  <user@example.com>
Subject: =?utf-8?Q?iPhone=20?=
 =?utf-8?Q?13=20?=
 =?utf-8?Q?Pro=2E=20?=
 =?utf-8?Q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B5=20?=
 =?utf-8?Q?=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D1=8B=2E=20?=
 =?utf-8?Q?=D0=9D=D0=B5=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?=
 =?utf-8?Q?=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=2E?=

```

Pull request:
envelope: Rely on stdlib for decoding to UTF-8
Update AddressList to rely on stdlib for decoding to UTF-8.  This is an inverted approach to that of #219 

I also extracted & exposed our parser and added unit tests so we don't have to add testdata file based tests for all address list scenarios.

While Go's address parsing has improved significantly since the initial release of enmime, it's possible we will still run into some regressions.  If that happens we can add a fall-back to using our own rfc2047 decoder.

Closes #218 

Patch:
diff --git a/envelope.go b/envelope.go
index bd49676..4303590 100644
--- a/envelope.go
+++ b/envelope.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/jaytaylor/html2text"
 	"github.com/jhillyerd/enmime/internal/coding"
-	"github.com/jhillyerd/enmime/internal/stringutil"
 	"github.com/jhillyerd/enmime/mediatype"
 	"github.com/pkg/errors"
 )
@@ -112,22 +111,7 @@ func (e *Envelope) AddressList(key string) ([]*mail.Address, error) {
 		return nil, fmt.Errorf("%s is not an address header", key)
 	}
 
-	str := decodeToUTF8Base64Header(e.header.Get(key))
-
-	// 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 err.Error() {
-		case "mail: expected comma":
-			return mail.ParseAddressList(stringutil.EnsureCommaDelimitedAddresses(str))
-		case "mail: no address":
-			return nil, mail.ErrHeaderNotPresent
-		}
-		return nil, err
-	}
-	return ret, nil
+	return ParseAddressList(e.header.Get(key))
 }
 
 // Clone returns a clone of the current Envelope
diff --git a/header.go b/header.go
index cd9d28b..5c72a97 100644
--- a/header.go
+++ b/header.go
@@ -5,10 +5,12 @@ import (
 	"bytes"
 	"fmt"
 	"mime"
+	"net/mail"
 	"net/textproto"
 	"strings"
 
 	"github.com/jhillyerd/enmime/internal/coding"
+	"github.com/jhillyerd/enmime/internal/stringutil"
 	"github.com/jhillyerd/enmime/mediatype"
 	"github.com/pkg/errors"
 )
@@ -72,6 +74,25 @@ var AddressHeaders = map[string]bool{
 	"resent-sender":   true,
 }
 
+// ParseAddressList returns a mail.Address slice with RFC 2047 encoded names converted to UTF-8.
+// It is more tolerant of malformed headers than the ParseAddressList func provided in Go's net/mail
+// package.
+func ParseAddressList(list string) ([]*mail.Address, error) {
+	ret, err := mail.ParseAddressList(list)
+	if err != nil {
+		switch err.Error() {
+		case "mail: expected comma":
+			// Attempt to add commas and parse again.
+			return mail.ParseAddressList(stringutil.EnsureCommaDelimitedAddresses(list))
+		case "mail: no address":
+			return nil, mail.ErrHeaderNotPresent
+		}
+		return nil, err
+	}
+
+	return ret, nil
+}
+
 // Terminology from RFC 2047:
 //  encoded-word: the entire =?charset?encoding?encoded-text?= string
 //  charset: the character set portion of the encoded word


Test patch:
diff --git a/header_test.go b/header_test.go
index 9238f6c..9145384 100644
--- a/header_test.go
+++ b/header_test.go
@@ -2,6 +2,7 @@ package enmime
 
 import (
 	"bufio"
+	"net/mail"
 	"net/textproto"
 	"strings"
 	"testing"
@@ -20,7 +21,7 @@ func TestDecodeToUTF8Base64Header(t *testing.T) {
 		{"=?UTF-8?Q?Miros=C5=82aw?= <u@h>", "=?UTF-8?b?TWlyb3PFgmF3?= <u@h>"},
 		{"First Last <u@h> (=?iso-8859-1?q?#=a3_c=a9_r=ae_u=b5?=)",
 			"First Last <u@h> (=?UTF-8?b?I8KjIGPCqSBywq4gdcK1?=)"},
-		// Quoted display name without space before angle-addr spec, Issue #112
+		// Quoted display name without space before angle-addr spec, issue #112
 		{"\"=?UTF-8?b?TWlyb3PFgmF3?=\"<u@h>", "=?UTF-8?b?Ik1pcm9zxYJhdyI=?= <u@h>"},
 	}
 
@@ -32,6 +33,79 @@ func TestDecodeToUTF8Base64Header(t *testing.T) {
 	}
 }
 
+func TestParseAddressListNoFailures(t *testing.T) {
+	testStrings := []string{
+		"user@example.com",
+		"Example User <user@example.com>",
+		`"The Example User" <user@example.com>`,
+		"=?UTF-8?Q?Miros=C5=82aw?= <u@h>",
+		"First Last <u@h> (=?iso-8859-1?q?#=a3_c=a9_r=ae_u=b5?=)",
+		// Quoted display name without space before angle-addr spec, issue #112
+		`"=?UTF-8?b?TWlyb3PFgmF3?="<u@h>`,
+		// Multiple encoded words containing a colon, issue #218
+		"=?utf-8?Q?ze=3AStore=20?= =?utf-8?Q?Orange=20?= =?utf-8?Q?Premium=20?= =?utf-8?Q?Reseller?= <noreply@mail.zestore.ru>",
+	}
+	for _, ts := range testStrings {
+		t.Run(ts, func(t *testing.T) {
+			_, err := ParseAddressList(ts)
+			if err != nil {
+				t.Errorf("Failed to parse address list %q:\n%v", ts, err)
+			}
+		})
+	}
+}
+
+func TestParseAddressListResult(t *testing.T) {
+	testCases := []struct {
+		input string
+		want  []*mail.Address
+	}{
+		{
+			"Example User <user@example.com>",
+			[]*mail.Address{{
+				Name:    "Example User",
+				Address: "user@example.com"}},
+		},
+		{
+			"a@h, b@h, c@h",
+			[]*mail.Address{
+				{Name: "", Address: "a@h"},
+				{Name: "", Address: "b@h"},
+				{Name: "", Address: "c@h"},
+			},
+		},
+		{
+			"=?utf-8?Q?ze=3AStore=20?= =?utf-8?Q?Orange=20?= =?utf-8?Q?Premium=20?= =?utf-8?Q?Reseller?= <noreply@mail.zestore.ru>",
+			[]*mail.Address{{
+				Name:    "ze:Store Orange Premium Reseller",
+				Address: "noreply@mail.zestore.ru"}},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.input, func(t *testing.T) {
+			got, err := ParseAddressList(tc.input)
+			if err != nil {
+				t.Errorf("Failed to parse address list %q:\n%v", tc.input, err)
+				return
+			}
+
+			if len(got) != len(tc.want) {
+				t.Errorf("Got %v addresses, wanted %v", len(got), len(tc.want))
+				return
+			}
+
+			for i, want := range tc.want {
+				if got[i].Name != want.Name {
+					t.Errorf("Got Name %q, wanted %q", got[i].Name, want.Name)
+				}
+				if got[i].Address != want.Address {
+					t.Errorf("Got Address %q, wanted %q", got[i].Address, want.Address)
+				}
+			}
+		})
+	}
+}
+
 func TestReadHeader(t *testing.T) {
 	// These values will surround the test table input string.
 	prefix := "From: hooman\n \n being\n"