Repository: jhillyerd/enmime
Pull request #359 (2024-12-13 13:05:10)

feat: encoding options added
James, please look at the proposed changes. #358 

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

coverage: 86.042% (-0.5%) from 86.507%
when pulling **5daef0d7fab07ff2666dbb020d8e098d80ade9a2 on pgradoboev:encoding-options**
into **1d9fc9cfdfa5080fc6c0235c57166fab72c56671 on jhillyerd:main**.


Patch:
diff --git a/encode.go b/encode.go
index 39d2a32..bef657b 100644
--- a/encode.go
+++ b/encode.go
@@ -117,7 +117,7 @@ func (p *Part) setupMIMEHeaders() transferEncoding {
 		} else {
 			cte = teBase64
 			if p.TextContent() && p.ContentReader == nil {
-				cte = selectTransferEncoding(p.Content, false)
+				cte = p.selectTransferEncoding(p.Content, false)
 				if p.Charset == "" {
 					p.Charset = utf8
 				}
@@ -144,7 +144,7 @@ func (p *Part) setupMIMEHeaders() transferEncoding {
 		p.Header.Set(hnContentID, coding.ToIDHeader(p.ContentID))
 	}
 	fileName := p.FileName
-	switch selectTransferEncoding([]byte(p.FileName), true) {
+	switch p.selectTransferEncoding([]byte(p.FileName), true) {
 	case teBase64:
 		fileName = mime.BEncoding.Encode(utf8, p.FileName)
 	case teQuoted:
@@ -195,7 +195,7 @@ func (p *Part) encodeHeader(b *bufio.Writer) error {
 		for _, v := range p.Header[k] {
 			encv := v
 			if !rawContent {
-				switch selectTransferEncoding([]byte(v), true) {
+				switch p.selectTransferEncoding([]byte(v), true) {
 				case teBase64:
 					encv = mime.BEncoding.Encode(utf8, v)
 				case teQuoted:
@@ -303,10 +303,15 @@ func (p *Part) encodeContentFromReader(b *bufio.Writer) error {
 }
 
 // selectTransferEncoding scans content for non-ASCII characters and selects 'b' or 'q' encoding.
-func selectTransferEncoding(content []byte, quoteLineBreaks bool) transferEncoding {
+func (p *Part) selectTransferEncoding(content []byte, quoteLineBreaks bool) transferEncoding {
 	if len(content) == 0 {
 		return te7Bit
 	}
+
+	if p.encoder != nil && p.encoder.forceQuotedPrintableCteOption {
+		return teQuoted
+	}
+
 	// Binary chars remaining before we choose b64 encoding.
 	threshold := b64Percent * len(content) / 100
 	bincount := 0
diff --git a/encoder_options.go b/encoder_options.go
new file mode 100644
index 0000000..e777903
--- /dev/null
+++ b/encoder_options.go
@@ -0,0 +1,33 @@
+package enmime
+
+type EncoderOption interface {
+	apply(p *Encoder)
+}
+
+// Encoder implements MIME part encoding options
+type Encoder struct {
+	forceQuotedPrintableCteOption bool
+}
+
+// ForceQuotedPrintableCte forces "quoted-printable" transfer encoding when selecting Content Transfer Encoding, preventing the use of base64.
+func ForceQuotedPrintableCte(b bool) EncoderOption {
+	return forceQuotedPrintableCteOption(b)
+}
+
+type forceQuotedPrintableCteOption bool
+
+func (o forceQuotedPrintableCteOption) apply(p *Encoder) {
+	p.forceQuotedPrintableCteOption = bool(o)
+}
+
+func NewEncoder(ops ...EncoderOption) *Encoder {
+	e := Encoder{
+		forceQuotedPrintableCteOption: false,
+	}
+
+	for _, o := range ops {
+		o.apply(&e)
+	}
+
+	return &e
+}
diff --git a/internal/stringutil/addr.go b/internal/stringutil/addr.go
index c3eaa42..4a02a83 100644
--- a/internal/stringutil/addr.go
+++ b/internal/stringutil/addr.go
@@ -34,6 +34,7 @@ func EnsureCommaDelimitedAddresses(s string) string {
 	inQuotes := false
 	inDomain := false
 	escapeSequence := false
+	inAngles := false
 	sb := strings.Builder{}
 	for i, r := range s {
 		if escapeSequence {
@@ -46,6 +47,19 @@ func EnsureCommaDelimitedAddresses(s string) string {
 			sb.WriteRune(r)
 			continue
 		}
+
+		if r == '<' {
+			inAngles = true
+			sb.WriteRune(r)
+			continue
+		}
+
+		if r == '>' {
+			inAngles = false
+			sb.WriteRune(r)
+			continue
+		}
+
 		if inQuotes {
 			if r == '\\' {
 				escapeSequence = true
@@ -74,13 +88,17 @@ func EnsureCommaDelimitedAddresses(s string) string {
 					sb.WriteRune(r)
 					continue
 				}
-				if r == ' ' {
+				if r == ' ' && !inAngles {
 					inDomain = false
 					sb.WriteRune(',')
 					sb.WriteRune(r)
 					continue
 				}
 			}
+
+			if inAngles && r == ' ' {
+				continue
+			}
 		}
 		sb.WriteRune(r)
 	}
diff --git a/part.go b/part.go
index d5eef85..ef39e5f 100644
--- a/part.go
+++ b/part.go
@@ -50,6 +50,8 @@ type Part struct {
 	parser *Parser // Provides access to parsing options.
 
 	randSource rand.Source // optional rand for uuid boundary generation
+
+	encoder *Encoder // provides encoding options
 }
 
 // NewPart creates a new Part object.
@@ -494,3 +496,10 @@ func parseParts(parent *Part, reader *bufio.Reader) error {
 	}
 	return nil
 }
+
+func (p *Part) WithEncoder(e *Encoder) *Part {
+	if e != nil {
+		p.encoder = e
+	}
+	return p
+}


Test patch:
diff --git a/encode_test.go b/encode_test.go
index 188a296..017e22e 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/jhillyerd/enmime/v2"
 	"github.com/jhillyerd/enmime/v2/internal/test"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestEncodePartEmpty(t *testing.T) {
@@ -340,6 +341,21 @@ func TestEncodePartContentBinary(t *testing.T) {
 	test.DiffGolden(t, b.Bytes(), "testdata", "encode", "part-bin-content.golden")
 }
 
+func TestEncodePartWithForceQuotedPrintableCte(t *testing.T) {
+	nonASCIIcontent := bytes.Repeat([]byte{byte(0x10)}, 10)
+	p := enmime.NewPart("text/plain").WithEncoder(enmime.NewEncoder(enmime.ForceQuotedPrintableCte(true)))
+	p.Content = nonASCIIcontent
+	b := &bytes.Buffer{}
+	err := p.Encode(b)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Verify output is QP encoded.
+	assert.Equal(t, "quoted-printable", p.Header.Get("Content-Transfer-Encoding"))
+	assert.Contains(t, b.String(), "=10=10=10")
+}
+
 func TestEncodeFileModDate(t *testing.T) {
 	p := enmime.NewPart("text/plain")
 	p.Content = []byte("¡Hola, señor! Welcome to MIME")
diff --git a/internal/stringutil/addr_test.go b/internal/stringutil/addr_test.go
index 0ce4eac..a4e3624 100644
--- a/internal/stringutil/addr_test.go
+++ b/internal/stringutil/addr_test.go
@@ -108,6 +108,22 @@ func TestCommaDelimitedAddressLists(t *testing.T) {
 			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" <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" <  joe@company.com  >;< other@company.com     >`,
+			want: `"Joe @ Company" <joe@company.com>,<other@company.com>`,
+		},
 	}
 	for _, tt := range testData {
 		t.Run(tt.have, func(t *testing.T) {